From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/22] Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 --- .../src/components/tools/item/move-tool.tsx | 6 +- .../components/tools/item/placement-math.ts | 26 ++++ .../tools/item/placement-strategies.ts | 88 ++++++++++++ .../components/tools/item/placement-types.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 135 +++++++++++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed20..eefaa2a79 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -40,12 +40,12 @@ function getInitialState(node: { }): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } + return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } } if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } + return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } + return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } } function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 49eacf304..112273a41 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { type AssetInput, isObject } from '@pascal-app/core' +import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -118,3 +119,28 @@ export function stripTransient(meta: any): any { const { isTransient, ...rest } = meta as Record return rest } + +const _up = new Vector3(0, 1, 0) +const _normal = new Vector3() +const _quat = new Quaternion() +const _euler = new Euler() + +/** + * Compute euler rotation that tilts an item so its local +Y aligns with a + * roof surface normal. The normal is in the hit mesh's local space and is + * transformed to world space via the mesh's matrixWorld. + */ +export function calculateRoofRotation( + normal: [number, number, number] | undefined, + objectMatrixWorld: Matrix4, +): [number, number, number] { + if (!normal) return [0, 0, 0] + + _normal.set(normal[0], normal[1], normal[2]) + _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize() + + _quat.setFromUnitVectors(_up, _normal) + _euler.setFromQuaternion(_quat, 'XYZ') + + return [_euler.x, _euler.y, _euler.z] +} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e8724081..5563268b8 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,6 +6,7 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, WallEvent, WallNode, } from '@pascal-app/core' @@ -19,6 +20,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + calculateRoofRotation, getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, @@ -587,6 +589,87 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// ROOF STRATEGY +// ============================================================================ + +export const roofStrategy = { + enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + if (!ctx.levelId) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + stateUpdate: { surface: 'roof', roofId: event.node.id }, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + parentId: ctx.levelId, + rotation, + }, + cursorRotationY: rotation[1], + cursorRotation: rotation, + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + stopPropagation: true, + } + }, + + move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + cursorRotationY: rotation[1], + cursorRotation: rotation, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + rotation, + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof') return null + + return { + stateUpdate: { surface: 'floor', roofId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: [0, ctx.currentCursorRotationY, 0], + }, + cursorRotationY: ctx.currentCursorRotationY, + cursorRotation: [0, ctx.currentCursorRotationY, 0], + gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +686,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Roof: valid if we entered (no spatial validator yet) + if (ctx.state.surface === 'roof') { + return ctx.state.roofId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 538286580..69a3d5ee3 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,7 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + roofId: string | null } // ============================================================================ @@ -58,6 +59,7 @@ export interface PlacementResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] nodeUpdate: Partial | null stopPropagation: boolean dirtyNodeId: AnyNode['id'] | null @@ -72,6 +74,7 @@ export interface TransitionResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] stopPropagation: boolean } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe3635..bac2b78fc 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type RoofEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -484,7 +486,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } const draft = draftNode.current if (draft) { @@ -498,12 +504,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(...result.gridPosition) const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } + + const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0] draftNode.create( gridPosition.current, asset, - [0, result.cursorRotationY, 0], + initRotation, configRef.current.defaultScale, ) @@ -1065,6 +1077,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Segment Handlers ---- + + const toRoofLocal = (result: TransitionResult): TransitionResult => { + const local = worldToBuildingLocal(...result.cursorPosition) + const localPos: [number, number, number] = [local.x, local.y, local.z] + return { + ...result, + gridPosition: localPos, + nodeUpdate: { ...result.nodeUpdate, position: localPos }, + } + } + + const onRoofEnter = (event: RoofEvent) => { + const result = roofStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + const local = toRoofLocal(result) + applyTransition(local) + + if (!draftNode.current) { + ensureDraft(local) + } + } + + const onRoofMove = (event: RoofEvent) => { + const ctx = getContext() + + if (ctx.state.surface !== 'roof') { + const enterResult = roofStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + const local = toRoofLocal(enterResult) + applyTransition(local) + if (!draftNode.current) { + ensureDraft(local) + } + return + } + + if (!draftNode.current) { + const enterResult = roofStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(toRoofLocal(enterResult)) + return + } + + const result = roofStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + const localPos = worldToBuildingLocal(...result.cursorPosition) + gridPosition.current.set(localPos.x, localPos.y, localPos.z) + cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.y = result.cursorRotationY + } + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + draft.position = [localPos.x, localPos.y, localPos.z] + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + mesh.rotation.set(...result.cursorRotation) + } + } + } + + revalidate() + } + + const onRoofClick = (event: RoofEvent) => { + const result = roofStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + revalidate() + } + } + + const onRoofLeave = (event: RoofEvent) => { + const result = roofStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + applyTransition(result) + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1354,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) return () => { tearingDown = true @@ -1263,6 +1382,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1307,7 +1430,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'roof') { + mesh.position.copy(gridPosition.current) + } else if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 7c1e3839c95c184dadb2b9e761b5da0520598f29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 20 May 2026 17:21:10 +0530 Subject: [PATCH 02/22] fixed conflict --- .../src/components/tools/item/move-tool.tsx | 69 ---------- .../tools/item/placement-strategies.ts | 84 ------------ .../components/tools/item/placement-types.ts | 8 -- .../tools/item/use-placement-coordinator.tsx | 127 +----------------- 4 files changed, 1 insertion(+), 287 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2d7f85723..d7c86be96 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -15,76 +15,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -<<<<<<< HEAD -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} -======= import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * MoveTool dispatcher. Routes to (in order): diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index fae9694e9..df67ca169 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,8 @@ import type { GridEvent, ItemEvent, ItemNode, -<<<<<<< HEAD - RoofEvent, -======= ShelfEvent, ShelfNode, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 WallEvent, WallNode, } from '@pascal-app/core' @@ -596,29 +592,6 @@ export const itemSurfaceStrategy = { } // ============================================================================ -<<<<<<< HEAD -// ROOF STRATEGY -// ============================================================================ - -export const roofStrategy = { - enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { - if (ctx.asset.attachTo) return null - if (!ctx.levelId) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - stateUpdate: { surface: 'roof', roofId: event.node.id }, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - parentId: ctx.levelId, - rotation, - }, - cursorRotationY: rotation[1], - cursorRotation: rotation, - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], -======= // SHELF SURFACE STRATEGY // ============================================================================ @@ -703,28 +676,10 @@ export const shelfSurfaceStrategy = { cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, rowY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, } }, -<<<<<<< HEAD - move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], - cursorRotationY: rotation[1], - cursorRotation: rotation, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - rotation, - }, -======= /** * Handle shelf:move — re-derive the closest row each tick so the user * can slide between rows without leaving the shelf. @@ -753,17 +708,11 @@ export const shelfSurfaceStrategy = { cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, rowY, z] }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null -======= /** * Handle shelf:click — commit placement on the active row. */ @@ -771,43 +720,17 @@ export const shelfSurfaceStrategy = { if (ctx.state.surface !== 'shelf-surface') return null if (!(ctx.draftItem && ctx.state.shelfId)) return null if (event.node.id !== ctx.state.shelfId) return null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], -<<<<<<< HEAD - parentId: ctx.levelId, - rotation: ctx.draftItem.rotation, -======= parentId: ctx.state.shelfId, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - - leave(ctx: PlacementContext): TransitionResult | null { - if (ctx.state.surface !== 'roof') return null - - return { - stateUpdate: { surface: 'floor', roofId: null }, - nodeUpdate: { - position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - parentId: ctx.levelId, - rotation: [0, ctx.currentCursorRotationY, 0], - }, - cursorRotationY: ctx.currentCursorRotationY, - cursorRotation: [0, ctx.currentCursorRotationY, 0], - gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - stopPropagation: true, - } - }, -======= } /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed @@ -816,7 +739,6 @@ export const shelfSurfaceStrategy = { * `event.normal` + `event.object`. */ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { return isUpwardItemSurfaceHit(event as unknown as ItemEvent) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ @@ -835,15 +757,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } -<<<<<<< HEAD - // Roof: valid if we entered (no spatial validator yet) - if (ctx.state.surface === 'roof') { - return ctx.state.roofId !== null -======= // Shelf surface: same — size check already happened on enter if (ctx.state.surface === 'shelf-surface') { return ctx.state.shelfId !== null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } const attachTo = ctx.draftItem.asset.attachTo diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 0a593ca75..a3eccc116 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,11 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -<<<<<<< HEAD -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' -======= export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * Tracks which surface the draft item is currently on. @@ -27,9 +23,6 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null -<<<<<<< HEAD - roofId: string | null -======= /** * Active shelf when `surface === 'shelf-surface'`. Items host on the * shelf board closest to the cursor's local Y; the row index isn't @@ -37,7 +30,6 @@ export interface PlacementState { * position via `shelfRowSurfaceYs`. */ shelfId: string | null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 362ddd1dd..b86e426c4 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,11 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, -<<<<<<< HEAD - type RoofEvent, -======= type ShelfEvent, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 sceneRegistry, spatialGridManager, useLiveTransforms, @@ -46,11 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, -<<<<<<< HEAD - roofStrategy, -======= shelfSurfaceStrategy, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -296,9 +288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( -<<<<<<< HEAD - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, -======= config.initialState ?? { surface: 'floor', wallId: null, @@ -306,7 +295,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -1206,58 +1194,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } -<<<<<<< HEAD - // ---- Roof Segment Handlers ---- - - const toRoofLocal = (result: TransitionResult): TransitionResult => { - const local = worldToBuildingLocal(...result.cursorPosition) - const localPos: [number, number, number] = [local.x, local.y, local.z] - return { - ...result, - gridPosition: localPos, - nodeUpdate: { ...result.nodeUpdate, position: localPos }, - } - } - - const onRoofEnter = (event: RoofEvent) => { - const result = roofStrategy.enter(getContext(), event) - if (!result) return - - event.stopPropagation() - const local = toRoofLocal(result) - applyTransition(local) - - if (!draftNode.current) { - ensureDraft(local) - } - } - - const onRoofMove = (event: RoofEvent) => { - const ctx = getContext() - - if (ctx.state.surface !== 'roof') { - const enterResult = roofStrategy.enter(ctx, event) - if (!enterResult) return - - event.stopPropagation() - const local = toRoofLocal(enterResult) - applyTransition(local) - if (!draftNode.current) { - ensureDraft(local) - } - return - } - - if (!draftNode.current) { - const enterResult = roofStrategy.enter(getContext(), event) - if (!enterResult) return - event.stopPropagation() - ensureDraft(toRoofLocal(enterResult)) - return - } - - const result = roofStrategy.move(ctx, event) -======= // ---- Shelf Handlers ---- // // Items can host on shelves the same way they host on tables and @@ -1299,34 +1235,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } const result = shelfSurfaceStrategy.move(ctx, event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() -<<<<<<< HEAD - const localPos = worldToBuildingLocal(...result.cursorPosition) - gridPosition.current.set(localPos.x, localPos.y, localPos.z) - cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - cursorGroupRef.current.rotation.set(...result.cursorRotation) - } else { - cursorGroupRef.current.rotation.y = result.cursorRotationY - } - - const draft = draftNode.current - if (draft && result.nodeUpdate) { - if ('rotation' in result.nodeUpdate) - draft.rotation = result.nodeUpdate.rotation as [number, number, number] - draft.position = [localPos.x, localPos.y, localPos.z] - const mesh = sceneRegistry.nodes.get(draft.id) - if (mesh) { - mesh.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - mesh.rotation.set(...result.cursorRotation) - } - } -======= gridPosition.current.set(...result.gridPosition) const ic = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) @@ -1341,16 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea position: result.cursorPosition, rotation: result.cursorRotationY, }) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } revalidate() } -<<<<<<< HEAD - const onRoofClick = (event: RoofEvent) => { - const result = roofStrategy.click(getContext(), event) -======= const onShelfLeave = (event: ShelfEvent) => { if (placementState.current.surface !== 'shelf-surface') return if (event.node.id !== placementState.current.shelfId) return @@ -1363,7 +1270,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onShelfClick = (event: ShelfEvent) => { const result = shelfSurfaceStrategy.click(getContext(), event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() @@ -1373,20 +1279,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { -<<<<<<< HEAD - revalidate() - } - } - - const onRoofLeave = (event: RoofEvent) => { - const result = roofStrategy.leave(getContext()) - if (!result) return - - event.stopPropagation() - applyTransition(result) - } - -======= const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -1396,7 +1288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1571,17 +1462,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.on('roof:enter', onRoofEnter) - emitter.on('roof:move', onRoofMove) - emitter.on('roof:click', onRoofClick) - emitter.on('roof:leave', onRoofLeave) -======= emitter.on('shelf:enter', onShelfEnter) emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return () => { tearingDown = true @@ -1606,17 +1490,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.off('roof:enter', onRoofEnter) - emitter.off('roof:move', onRoofMove) - emitter.off('roof:click', onRoofClick) - emitter.off('roof:leave', onRoofLeave) -======= emitter.off('shelf:enter', onShelfEnter) emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1667,9 +1544,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'roof') { - mesh.position.copy(gridPosition.current) - } else if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From ed2d4b139273e208ccb7facd89a8029ac670ec20 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 8 Jun 2026 14:34:04 +0530 Subject: [PATCH 03/22] Snapshot: ridge vent locks to segment ridge during placement, free sliders after Placement tool clamps the cursor to the segment's ridge line for all roof types (gable, hip, shed, gambrel, dutch, mansard; flat rejected). Renderer re-derives Y from the live surface plus a small lift, and treats stored position[1] / position[2] as user offsets so the inspector sliders move the vent after placement. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/nodes/src/ridge-vent/move-tool.tsx | 19 +++++++- packages/nodes/src/ridge-vent/panel.tsx | 8 +--- packages/nodes/src/ridge-vent/renderer.tsx | 25 +++++++---- packages/nodes/src/ridge-vent/tool.tsx | 18 +++++--- packages/nodes/src/shared/ridge-snap.ts | 48 +++++++++++++++++++++ 5 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 packages/nodes/src/shared/ridge-snap.ts diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index f7890d6f8..7e7d0d4e0 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -14,6 +14,7 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' +import { resolveRidgeSnap } from '../shared/ridge-snap' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import RidgeVentPreview from './preview' @@ -77,8 +78,20 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) if (!hit) return + // Project the cursor onto the segment's ridge line so the ghost + // tracks ALONG the ridge — never off it. Flat segments have none. + const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) + if (!snap) { + setPreviewPos(null) + return + } + const segObj = sceneRegistry.nodes.get(hit.segment.id) + const ridgeWorld = segObj + ? segObj.localToWorld(new THREE.Vector3(snap.localX, hit.localY, snap.localZ)) + : new THREE.Vector3(wx, wy, wz) + setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewPos(worldToBuildingLocal(ridgeWorld.x, ridgeWorld.y, ridgeWorld.z)) event.stopPropagation() } @@ -90,6 +103,8 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { event.position[2], ) if (!hit) return + const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) + if (!snap) return const targetSegmentId = hit.segment.id as AnyNodeId const st = useScene.getState() @@ -114,7 +129,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [snap.localX, 0, snap.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/ridge-vent/panel.tsx b/packages/nodes/src/ridge-vent/panel.tsx index 566c0ed8f..dd0f2e7cc 100644 --- a/packages/nodes/src/ridge-vent/panel.tsx +++ b/packages/nodes/src/ridge-vent/panel.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - getActiveRoofHeight, RidgeVentNode as RidgeVentSchema, type RoofSegmentNode, useScene, @@ -217,11 +216,8 @@ export default function RidgeVentPanel() { /> handleUpdate({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0], diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index aa9935169..daca8b5f8 100644 --- a/packages/nodes/src/ridge-vent/renderer.tsx +++ b/packages/nodes/src/ridge-vent/renderer.tsx @@ -18,6 +18,7 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap' import { getSurfaceY } from '../shared/roof-surface' import { buildRidgeVentGeometry } from './geometry' @@ -109,18 +110,26 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { const segPos = segment.position ?? [0, 0, 0] const segRotY = segment.rotation ?? 0 - // Seat the vent on the ridge by DERIVING its Y from the segment's current - // surface rather than the stored `position[1]`. The ridge height comes from - // the segment's pitch (`getActiveRoofHeight`), so when the roof is lowered - // the segment updates, this renderer re-runs, and the vent rides the ridge - // down automatically — no stale floating cap. X/Z stay as authored (the vent - // straddles the ridge line at localZ≈0). - const ridgeY = getSurfaceY(node.position[0] ?? 0, node.position[2] ?? 0, segment) + // Lock the BASE position to the ridge so the vent always starts on the + // slope top; treat `position[1]` and `position[2]` as user-tunable OFFSETS + // off that base (Y above ridge lift, Z away from ridge centerline). So + // after placement the inspector's Y / Z sliders nudge the vent off the + // locked ridge without losing the slope-tracking base. X is the position + // along the ridge — the snap re-clamps it to the segment's ridge span. + const snap = resolveRidgeSnap(segment, node.position[0] ?? 0, 0) + const ridgeX = snap ? snap.localX : (node.position[0] ?? 0) + const baseZ = snap ? snap.localZ : 0 + const baseY = getSurfaceY(ridgeX, baseZ, segment) + RIDGE_LIFT + // Clamp legacy stored Y (absolute peak height from earlier versions) so the + // vent doesn't fly off when the field was an absolute Y instead of offset. + const yOffset = Math.max(-2, Math.min(2, node.position[1] ?? 0)) + const ridgeY = baseY + yOffset + const ridgeZ = baseZ + (node.position[2] ?? 0) return ( { ) if (!hit) return - // Snap the cursor to the ridge by zeroing localZ via the - // segment's local frame, then convert back through the building. + // Project the cursor onto the segment's ridge line (clamped to the + // segment's ridge span). The preview then moves ALONG the ridge as the + // cursor moves — never off it. Flat segments have no ridge: hide. + const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) + if (!snap) { + setPreviewPos(null) + return + } const segObj = sceneRegistry.nodes.get(hit.segment.id) let ridgeWorld: [number, number, number] if (segObj) { - const ridgeLocal = new THREE.Vector3(hit.localX, hit.localY, 0) + const ridgeLocal = new THREE.Vector3(snap.localX, hit.localY, snap.localZ) segObj.updateWorldMatrix(true, false) ridgeLocal.applyMatrix4(segObj.matrixWorld) ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z] @@ -99,14 +106,15 @@ const RidgeVentTool = () => { event.position[2], ) if (!hit) return + const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) + if (!snap) return const state = useScene.getState() const vent = RidgeVentNode.parse({ ...ridgeVentDefinition.defaults(), name: 'Ridge Vent', roofSegmentId: hit.segment.id, - // Snap Z to 0 — ridge vents straddle the ridge line. - position: [hit.localX, hit.localY, 0], + position: [snap.localX, 0, snap.localZ], rotation: 0, }) state.createNode(vent, hit.segment.id as AnyNodeId) diff --git a/packages/nodes/src/shared/ridge-snap.ts b/packages/nodes/src/shared/ridge-snap.ts new file mode 100644 index 000000000..8a2af4760 --- /dev/null +++ b/packages/nodes/src/shared/ridge-snap.ts @@ -0,0 +1,48 @@ +import type { RoofSegmentNode } from '@pascal-app/core' + +/** + * Shared ridge-line snap math for ridge-vent placement + move tools. + * + * Ridge vents must sit centered on the segment's ridge — off-ridge the + * cap's far half dips into the higher part of the slope ("goes inside" + * the roof). So the placement tools clamp the cursor onto the ridge: + * closest-point projection along the segment's local X axis, with the X + * span clipped to where a real ridge actually exists for that roof type. + * + * Per roof type (the segment's ridge runs along the segment's local X): + * - gable / gambrel / dutch / mansard: ridge spans the full width. + * - hip: ridge is shortened by the hipped ends — spans width − depth. + * A square hip (width ≤ depth) collapses to a single apex point. + * - shed: no true ridge — snap to the high eave (z = -depth/2). + * - flat: no ridge at all → return null. + */ + +// Standard lift above the analytical slope surface so the cap reads as +// sitting on the shingle course rather than clipping into it. Shared +// with the renderer so live ridge-Y derivation matches placement. +export const RIDGE_LIFT = 0.12 + +export type RidgeSnap = { + /** Segment-local X of the snapped ridge position. */ + localX: number + /** Segment-local Z of the snapped ridge position (0 for peaked roofs). */ + localZ: number +} + +export function resolveRidgeSnap( + segment: RoofSegmentNode, + cursorLocalX: number, + _cursorLocalZ: number, +): RidgeSnap | null { + const roofType = segment.roofType ?? 'gable' + if (roofType === 'flat') return null + + const halfW = (segment.width ?? 0) / 2 + const halfD = (segment.depth ?? 0) / 2 + + const ridgeZ = roofType === 'shed' ? -halfD : 0 + const ridgeHalfLength = roofType === 'hip' ? Math.max(0, halfW - halfD) : halfW + const localX = Math.max(-ridgeHalfLength, Math.min(ridgeHalfLength, cursorLocalX)) + + return { localX, localZ: ridgeZ } +} From ae5e9c0fed5da428eeec72e8270d78dd73995e14 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 8 Jun 2026 14:45:23 +0530 Subject: [PATCH 04/22] feat(roof): contribute outer silhouette to alignment-guide candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roof has no centred-box footprint (it's the union of its `roof-segment` children) so the capability bridge needs the resolved AABB directly. We build it from the children's corners in roof-local space, then transform to world coords. Roofs only contribute as static candidates — the move-roof tool drives them by origin, so the relocatable-box path never applies. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/nodes/src/roof/definition.ts | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index bd86a3930..fcc70b6e6 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -108,6 +108,51 @@ export const roofDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + // Contribute a plan AABB to the alignment-guide candidate pool so a roof + // (and any moving sibling) snaps against the roof's outer silhouette. + // Roof has no centred-box footprint — it's the union of its + // `roof-segment` children — so we hand the bridge a resolved `aabb` + // directly. The roof moves by its origin via `move-roof-tool`, so it + // only ever contributes static candidates; the relocatable-box path + // never needs to apply to roofs. + alignmentFootprint: (node, nodes) => { + const roof = node as RoofNodeType + if (!nodes) return null + const cos = Math.cos(roof.rotation ?? 0) + const sin = Math.sin(roof.rotation ?? 0) + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + let any = false + for (const childId of roof.children ?? []) { + const segment = nodes[childId as AnyNodeId] as RoofSegmentNode | undefined + if (segment?.type !== 'roof-segment') continue + const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2 + const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2 + const sCos = Math.cos(segment.rotation ?? 0) + const sSin = Math.sin(segment.rotation ?? 0) + for (const [cx, cz] of [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] as const) { + // Segment corner → roof-local. + const rx = segment.position[0] + cx * sCos + cz * sSin + const rz = segment.position[2] - cx * sSin + cz * sCos + // Roof-local → world (apply roof rotation, then position). + const wx = roof.position[0] + rx * cos + rz * sin + const wz = roof.position[2] - rx * sin + rz * cos + if (wx < minX) minX = wx + if (wx > maxX) maxX = wx + if (wz < minZ) minZ = wz + if (wz > maxZ) maxZ = wz + any = true + } + } + return any ? { shape: 'aabb', minX, minZ, maxX, maxZ } : null + }, }, // Bespoke free-floating move (drag-to-place with R/T rotation and From c8b8a03e468d2ecaee00bd805c849b2969f6b70c Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 8 Jun 2026 14:45:30 +0530 Subject: [PATCH 05/22] feat(floorplan): upright text geometry that stays horizontal under scene rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floor-plan text labels were rotating with the 90° default scene rotation, making zone names read sideways. Add an `upright` flag to text geometry: when set, the registry layer counter-rotates the label by sceneRotationDeg around its anchor so it reads horizontally on screen. Zone labels opt in. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/registry/types.ts | 6 ++++ .../renderers/floorplan-registry-layer.tsx | 31 +++++++++++++++++++ packages/nodes/src/zone/floorplan.ts | 1 + 3 files changed, 38 insertions(+) diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 6013f5fdf..aa77b64aa 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -241,6 +241,12 @@ export type FloorplanGeometry = stroke?: string strokeWidth?: number paintOrder?: 'stroke' | 'fill' | 'normal' + /** + * When true, the registry layer counter-rotates the label by + * `sceneRotationDeg` so it reads horizontally on screen regardless + * of the floor-plan's scene rotation (default 90°). + */ + upright?: boolean } /** * Bitmap overlay — captured top-down asset thumbnail, AI-generated diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index f27326f25..764e084b4 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -1561,6 +1561,37 @@ function InteractiveGeometry({ ) } + case 'text': { + if (!g.upright) return + // Counter-rotate by the scene rotation so the label reads + // horizontally on screen even when the floor-plan view is + // rotated (default `sceneRotationDeg` is 90°). + return ( + + + {g.text} + + + ) + } default: return } diff --git a/packages/nodes/src/zone/floorplan.ts b/packages/nodes/src/zone/floorplan.ts index a488f9244..fc4f39a1b 100644 --- a/packages/nodes/src/zone/floorplan.ts +++ b/packages/nodes/src/zone/floorplan.ts @@ -105,6 +105,7 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp textAnchor: 'middle', dominantBaseline: 'central', opacity: showSelectedChrome ? 1 : 0.92, + upright: true, }) } From c3f4dbc20478b2f88d1fa1bcf6a45cbd4e5b9b41 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 8 Jun 2026 14:45:37 +0530 Subject: [PATCH 06/22] feat(editor): redesign material-paint cursor badge and gate floor-plan paint mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paint cursor swaps the inline icon+label chip for a stacked badge — a glowing accent stem pointing down to the hit point with the paint icon above — so the cue reads as "pointer" rather than "tooltip". Switches the accent to indigo. The 2D floor-plan now also shows the paint-icon overlay and routes pointer interactions to the painter when in material-paint mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../floorplan-cursor-indicator-overlay.tsx | 4 + .../src/components/editor/floorplan-panel.tsx | 3 +- .../editor/src/components/editor/index.tsx | 88 ++++++++----------- 3 files changed, 42 insertions(+), 53 deletions(-) diff --git a/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx index 64dbf2903..731925fa5 100644 --- a/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx @@ -76,6 +76,10 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi return { kind: 'icon', icon: 'mdi:trash-can-outline' } } + if (mode === 'material-paint') { + return { kind: 'asset', iconSrc: '/icons/paint.png' } + } + return null }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer]) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index c7f2a466b..439a58ec0 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -8475,7 +8475,8 @@ export function FloorplanPanel() { Boolean(movingOpeningType) || (mode === 'build' && tool !== null) || (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') || - mode === 'delete' + mode === 'delete' || + mode === 'material-paint' const handleSvgPointerMove = useCallback( (event: ReactPointerEvent) => { diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 5c6e5d758..3d551e83a 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -76,7 +76,7 @@ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint- const DELETE_CURSOR_BADGE_COLOR = '#ef4444' const DELETE_CURSOR_BADGE_OFFSET_X = 14 const DELETE_CURSOR_BADGE_OFFSET_Y = 14 -const PAINT_CURSOR_BADGE_COLOR = '#f59e0b' +const PAINT_CURSOR_BADGE_COLOR = '#818cf8' const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8' const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14 @@ -535,43 +535,46 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } }) function PaintCursorBadge({ position, - label, disabled, - icon, }: { position: { x: number; y: number } - label: string disabled: boolean - icon: string }) { const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR + const lineHeight = 18 return (