From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/17] 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/17] 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 27cd59bdda4b8cd580208db9ed691e918645d03c Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 3 Jun 2026 14:15:36 +0530 Subject: [PATCH 03/17] feat(editor): 3D alignment guides for item/wall/fence move + placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring Figma-style alignment guides into the 3D editor, reusing the shared pure resolver (`resolveAlignment`) and ephemeral guide store (`useAlignmentGuides`) that previously only drove the 2D floor plan. Core: - `alignment-anchors.ts`: node→anchor adapters (footprint AABBs, corner anchors, wall/fence segment anchors) + `refineGuidesToGap` so a guide's line and distance read to the candidate's nearest edge, not the far side. - `bboxCornerAnchors` + corner-only footprint anchors so alignment locks to item edges, never centrelines. - `resolvePointSnap` (point-coincidence variant; kept for future use). Editor: - `Alignment3DGuideLayer`: dashed ribbon + flat floor dots + distance pill, in the project's indigo accent, mounted inside ToolManager's building-local group so guides render in the cursor's frame. - Producers wired in the item move tool, item placement coordinator, and the wall + fence endpoint tools; walls and fences cross-align. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/services/alignment-anchors.test.ts | 230 ++++++++++++++++++ .../core/src/services/alignment-anchors.ts | 213 ++++++++++++++++ packages/core/src/services/alignment.test.ts | 52 +++- packages/core/src/services/alignment.ts | 87 +++++++ packages/core/src/services/index.ts | 18 +- .../editor/alignment-3d-guide-layer.tsx | 143 +++++++++++ .../tools/item/use-placement-coordinator.tsx | 100 +++++++- .../registry/move-registry-node-tool.tsx | 59 ++++- .../src/components/tools/tool-manager.tsx | 5 + .../nodes/src/fence/actions/move-endpoint.ts | 73 +++++- .../nodes/src/fence/move-endpoint-tool.tsx | 12 +- .../nodes/src/wall/move-endpoint-tool.tsx | 72 +++++- 12 files changed, 1038 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/services/alignment-anchors.test.ts create mode 100644 packages/core/src/services/alignment-anchors.ts create mode 100644 packages/editor/src/components/editor/alignment-3d-guide-layer.tsx diff --git a/packages/core/src/services/alignment-anchors.test.ts b/packages/core/src/services/alignment-anchors.test.ts new file mode 100644 index 000000000..8ce99b0cc --- /dev/null +++ b/packages/core/src/services/alignment-anchors.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../registry' +import type { AnyNodeDefinition } from '../registry/types' +import type { AnyNode } from '../schema/types' +import type { AlignmentGuide } from './alignment' +import { + collectAlignmentCandidates, + collectFloorFootprints, + type FootprintAABB, + footprintAABB, + footprintAABBFrom, + movingFootprintAnchors, + refineGuidesToGap, + wallSegmentAnchors, +} from './alignment-anchors' + +// Minimal floor-placed def whose footprint reads `dimensions` / `rotation` +// straight off the node, so tests can drive the AABB math directly. +function floorPlacedDef(kind: string, applies?: (n: AnyNode) => boolean): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as any, + category: 'utility', + defaults: () => ({}) as any, + capabilities: { + floorPlaced: { + footprint: (n: AnyNode) => ({ + dimensions: (n as { dimensions?: [number, number, number] }).dimensions ?? [1, 1, 1], + rotation: (n as { rotation?: [number, number, number] }).rotation ?? [0, 0, 0], + }), + ...(applies ? { applies } : {}), + }, + }, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition +} + +function plainDef(kind: string): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as any, + category: 'utility', + defaults: () => ({}) as any, + capabilities: {}, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition +} + +const node = (over: Record): AnyNode => over as unknown as AnyNode + +describe('footprintAABBFrom', () => { + test('unrotated box is centred at position', () => { + const aabb = footprintAABBFrom([10, 0, 20], [2, 1, 4], 0) + expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 }) + }) + + test('90° rotation swaps width and depth extents', () => { + const aabb = footprintAABBFrom([0, 0, 0], [2, 1, 4], Math.PI / 2) + expect(aabb.minX).toBeCloseTo(-2, 10) + expect(aabb.maxX).toBeCloseTo(2, 10) + expect(aabb.minZ).toBeCloseTo(-1, 10) + expect(aabb.maxZ).toBeCloseTo(1, 10) + }) +}) + +describe('footprintAABB', () => { + beforeEach(() => nodeRegistry._reset()) + + test('reads dimensions + rotation from a floor-placed kind', () => { + registerNode(floorPlacedDef('box')) + const aabb = footprintAABB( + node({ id: 'b1', type: 'box', position: [10, 0, 20], dimensions: [2, 1, 4] }), + ) + expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 }) + }) + + test('returns null for a kind without a footprint', () => { + registerNode(plainDef('wall')) + expect(footprintAABB(node({ id: 'w1', type: 'wall', position: [0, 0, 0] }))).toBeNull() + }) + + test('returns null when the kind predicate excludes the node', () => { + registerNode(floorPlacedDef('lamp', (n) => !(n as { attached?: boolean }).attached)) + expect( + footprintAABB(node({ id: 'l1', type: 'lamp', position: [0, 0, 0], attached: true })), + ).toBeNull() + expect( + footprintAABB(node({ id: 'l2', type: 'lamp', position: [0, 0, 0], attached: false })), + ).not.toBeNull() + }) +}) + +describe('collectAlignmentCandidates', () => { + beforeEach(() => nodeRegistry._reset()) + + test('excludes the moving node and skips footprintless kinds', () => { + registerNode(floorPlacedDef('box')) + registerNode(plainDef('wall')) + const nodes = { + moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }), + other: node({ id: 'other', type: 'box', position: [5, 0, 5], dimensions: [1, 1, 1] }), + wall: node({ id: 'wall', type: 'wall', position: [2, 0, 2] }), + } + const anchors = collectAlignmentCandidates(nodes, 'moving') + // Only `other` contributes — 4 corner anchors (edges only), none from the + // moving node or the wall. + expect(anchors).toHaveLength(4) + expect(anchors.every((a) => a.nodeId === 'other')).toBe(true) + expect(anchors.every((a) => a.kind === 'corner')).toBe(true) + }) +}) + +describe('movingFootprintAnchors', () => { + beforeEach(() => nodeRegistry._reset()) + + test('relocates the footprint corners around the proposed centre (edges only, no centre anchor)', () => { + registerNode(floorPlacedDef('box')) + const anchors = movingFootprintAnchors( + node({ id: 'm', type: 'box', position: [0, 0, 0], dimensions: [2, 1, 4] }), + 10, + 20, + ) + // 2×4 box centred at (10, 20): corners at x∈{9,11}, z∈{18,22}. + expect(anchors).toHaveLength(4) + expect(anchors.every((a) => a.kind === 'corner')).toBe(true) + expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9, 11])) + expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([18, 22])) + }) + + test('rotationY override drives the AABB regardless of node rotation', () => { + registerNode(floorPlacedDef('box')) + const anchors = movingFootprintAnchors( + node({ + id: 'm', + type: 'box', + position: [0, 0, 0], + dimensions: [2, 1, 4], + rotation: [0, 0, 0], + }), + 0, + 0, + Math.PI / 2, + ) + const xs = anchors.map((a) => a.x) + // Rotated 90°, the 2×4 box spans ±2 in X (its depth) rather than ±1. + expect(Math.max(...xs)).toBeCloseTo(2, 10) + expect(Math.min(...xs)).toBeCloseTo(-2, 10) + }) + + test('returns empty for a footprintless kind', () => { + registerNode(plainDef('wall')) + expect( + movingFootprintAnchors(node({ id: 'w', type: 'wall', position: [0, 0, 0] }), 1, 1), + ).toEqual([]) + }) +}) + +describe('collectFloorFootprints', () => { + beforeEach(() => nodeRegistry._reset()) + + test('maps floor-placed nodes by id, excluding the moving node and plain kinds', () => { + registerNode(floorPlacedDef('box')) + registerNode(plainDef('wall')) + const nodes = { + moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }), + other: node({ id: 'other', type: 'box', position: [5, 0, 5], dimensions: [2, 1, 4] }), + wall: node({ id: 'wall', type: 'wall', position: [2, 0, 2] }), + } + const map = collectFloorFootprints(nodes, 'moving') + expect([...map.keys()]).toEqual(['other']) + expect(map.get('other')).toEqual({ minX: 4, minZ: 3, maxX: 6, maxZ: 7 }) + }) +}) + +describe('refineGuidesToGap', () => { + const guideX = (coord: number, candidateNodeId: string): AlignmentGuide => ({ + axis: 'x', + coord, + from: { x: coord, z: 0 }, + to: { x: coord, z: 0 }, + movingAnchorKind: 'edge-mid', + candidateAnchorKind: 'edge-mid', + candidateNodeId, + distance: 0, + }) + + test('measures the gap between nearest facing edges, not anchor-to-anchor', () => { + // Moving sits past the candidate along Z; gap is moving.minZ − candidate.maxZ. + const moving: FootprintAABB = { minX: 0, minZ: 3, maxX: 2, maxZ: 5 } + const footprints = new Map([ + ['c', { minX: 0, minZ: 0, maxX: 2, maxZ: 2 }], + ]) + const [g] = refineGuidesToGap([guideX(1, 'c')], moving, footprints) + expect(g!.distance).toBeCloseTo(1, 10) // 3 − 2, not center-to-center (4) + expect(g!.from.z).toBeCloseTo(2, 10) // candidate near edge + expect(g!.to.z).toBeCloseTo(3, 10) // moving near edge + }) + + test('overlapping footprints have zero gap and span the union', () => { + const moving: FootprintAABB = { minX: 0, minZ: 1, maxX: 2, maxZ: 3 } + const footprints = new Map([ + ['c', { minX: 0, minZ: 0, maxX: 2, maxZ: 2 }], + ]) + const [g] = refineGuidesToGap([guideX(1, 'c')], moving, footprints) + expect(g!.distance).toBe(0) + expect(g!.from.z).toBeCloseTo(0, 10) + expect(g!.to.z).toBeCloseTo(3, 10) + }) + + test('passes guides through unchanged when the candidate is absent', () => { + const moving: FootprintAABB = { minX: 0, minZ: 0, maxX: 1, maxZ: 1 } + const original = guideX(1, 'missing') + const [g] = refineGuidesToGap([original], moving, new Map()) + expect(g).toEqual(original) + }) +}) + +describe('wallSegmentAnchors', () => { + test('returns both endpoints as corners and the chord midpoint as center', () => { + const anchors = wallSegmentAnchors('w', [0, 0], [4, 2]) + expect(anchors).toEqual([ + { nodeId: 'w', kind: 'corner', x: 0, z: 0 }, + { nodeId: 'w', kind: 'corner', x: 4, z: 2 }, + { nodeId: 'w', kind: 'center', x: 2, z: 1 }, + ]) + }) +}) diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts new file mode 100644 index 000000000..ea4a0fa9c --- /dev/null +++ b/packages/core/src/services/alignment-anchors.ts @@ -0,0 +1,213 @@ +/** + * Node → alignment-anchor adapters. + * + * `alignment.ts` is pure geometry and knows nothing about nodes. This + * module bridges the scene graph to it: it reads a floor-placed kind's + * footprint from the registry and turns it into the bbox anchors the + * resolver matches against. Kept out of `alignment.ts` so that file stays + * registry-free. + * + * All coordinates are XZ meters in the same frame as `node.position` + * (building-local for nodes inside a building). The 3D move producer works + * entirely in that frame, so the resulting guides line up with the cursor. + */ + +import { nodeRegistry } from '../registry' +import type { AnyNode } from '../schema/types' +import { type AlignmentAnchor, type AlignmentGuide, bboxCornerAnchors } from './alignment' + +export type FootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number } + +/** + * Axis-aligned XZ bounding box of a rotated rectangle centred at + * `position`. Mirrors the rotated-corner math the spatial-grid manager + * uses (`getItemFootprint`) so alignment anchors coincide with the + * footprint used for collision / slab elevation. + */ +export function footprintAABBFrom( + position: readonly [number, number, number], + dimensions: readonly [number, number, number], + rotationY: number, +): FootprintAABB { + const [x, , z] = position + const [w, , d] = dimensions + const halfW = w / 2 + const halfD = d / 2 + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + + let minX = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [lx, lz] of [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] as const) { + const wx = x + (lx * cos - lz * sin) + const wz = z + (lx * sin + lz * cos) + if (wx < minX) minX = wx + if (wx > maxX) maxX = wx + if (wz < minZ) minZ = wz + if (wz > maxZ) maxZ = wz + } + + return { minX, minZ, maxX, maxZ } +} + +/** The floor-placed footprint config for a node, or null when it has none + * (walls / slabs / polygon kinds) or the kind's predicate excludes it + * (e.g. a wall-attached item that doesn't rest on the floor). */ +function floorFootprint( + node: AnyNode, +): { dimensions: [number, number, number]; rotation: [number, number, number] } | null { + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced) return null + if (floorPlaced.applies && !floorPlaced.applies(node)) return null + return floorPlaced.footprint(node) +} + +/** XZ footprint AABB of a floor-placed node at its current position, or + * null for kinds without a usable footprint. */ +export function footprintAABB(node: AnyNode): FootprintAABB | null { + const fp = floorFootprint(node) + if (!fp) return null + const position = (node as { position?: [number, number, number] }).position ?? [0, 0, 0] + return footprintAABBFrom(position, fp.dimensions, fp.rotation[1] ?? 0) +} + +/** XZ footprint AABB of a floor-placed node relocated so its centre sits at + * the proposed (x, z). `rotationY` overrides the node's footprint rotation + * (R/T bumps it before the scene commit lands). Null when no footprint. */ +export function footprintAABBAt( + node: AnyNode, + x: number, + z: number, + rotationY?: number, +): FootprintAABB | null { + const fp = floorFootprint(node) + if (!fp) return null + return footprintAABBFrom([x, 0, z], fp.dimensions, rotationY ?? fp.rotation[1] ?? 0) +} + +/** + * Footprint AABBs of every floor-placed node except `excludeId`, keyed by + * node id. The static pool both the resolver (via {@link footprintAnchors}) + * and the gap refinement (via {@link refineGuidesToGap}) draw from. Kinds + * without a footprint are omitted (bbox-anchors-only, matching v1). + */ +export function collectFloorFootprints( + nodes: Readonly>, + excludeId: string, +): Map { + const footprints = new Map() + for (const node of Object.values(nodes)) { + if (!node || node.id === excludeId) continue + const aabb = footprintAABB(node) + if (aabb) footprints.set(node.id, aabb) + } + return footprints +} + +/** Flatten a footprint map into the corner anchors the resolver matches. + * Corners only — alignment locks to item edges, never centrelines. */ +export function footprintAnchors( + footprints: ReadonlyMap, +): AlignmentAnchor[] { + const anchors: AlignmentAnchor[] = [] + for (const [id, b] of footprints) { + anchors.push(...bboxCornerAnchors(id, b.minX, b.minZ, b.maxX, b.maxZ)) + } + return anchors +} + +/** + * Convenience: candidate anchors from every other floor-placed node. + * Equivalent to `footprintAnchors(collectFloorFootprints(nodes, excludeId))`. + */ +export function collectAlignmentCandidates( + nodes: Readonly>, + excludeId: string, +): AlignmentAnchor[] { + return footprintAnchors(collectFloorFootprints(nodes, excludeId)) +} + +/** + * Corner anchors for the moving node's footprint relocated so its centre + * sits at the proposed (x, z). Corners only — the moving item aligns by its + * edges, never its centreline. Returns [] when the kind has no footprint. + */ +export function movingFootprintAnchors( + node: AnyNode, + x: number, + z: number, + rotationY?: number, +): AlignmentAnchor[] { + const aabb = footprintAABBAt(node, x, z, rotationY) + if (!aabb) return [] + return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) +} + +/** + * Alignment anchors for a wall segment: both endpoints (as `corner`) and + * the chord midpoint (as `center`). Curve offset is ignored — endpoints are + * exact and the midpoint is good enough for v1 alignment. Coordinates are + * the wall's `start` / `end` (building-local XZ meters). + */ +export function wallSegmentAnchors( + id: string, + start: readonly [number, number], + end: readonly [number, number], +): AlignmentAnchor[] { + return [ + { nodeId: id, kind: 'corner', x: start[0], z: start[1] }, + { nodeId: id, kind: 'corner', x: end[0], z: end[1] }, + { nodeId: id, kind: 'center', x: (start[0] + end[0]) / 2, z: (start[1] + end[1]) / 2 }, + ] +} + +/** Nearest-edge gap between two 1-D intervals and the two facing edges that + * bound it. When the intervals overlap there is no gap: returns the union + * span so the alignment line still reads across both, with `gap` 0. */ +function intervalGap( + aMin: number, + aMax: number, + bMin: number, + bMax: number, +): { gap: number; near: number; far: number } { + if (aMin >= bMax) return { gap: aMin - bMax, near: bMax, far: aMin } // a after b + if (aMax <= bMin) return { gap: bMin - aMax, near: aMax, far: bMin } // a before b + return { gap: 0, near: Math.min(aMin, bMin), far: Math.max(aMax, bMax) } // overlap +} + +/** + * Rewrite resolver guides so each spans (and measures) the gap between the + * NEAREST facing edges of the moving and candidate footprints, rather than + * the matched anchor-to-anchor span — which could run to the far side of an + * item. The line stays on the matched axis (`guide.coord`); only its extent + * along the perpendicular axis and its `distance` change. + * + * `movingAABB` should be the moving footprint at its post-snap position. + * Guides whose candidate isn't in `footprints` pass through unchanged. + */ +export function refineGuidesToGap( + guides: readonly AlignmentGuide[], + movingAABB: FootprintAABB, + footprints: ReadonlyMap, +): AlignmentGuide[] { + return guides.map((g) => { + const cb = footprints.get(g.candidateNodeId) + if (!cb) return g + if (g.axis === 'x') { + // Shared X = g.coord; gap measured along Z. + const { gap, near, far } = intervalGap(movingAABB.minZ, movingAABB.maxZ, cb.minZ, cb.maxZ) + return { ...g, from: { x: g.coord, z: near }, to: { x: g.coord, z: far }, distance: gap } + } + // Shared Z = g.coord; gap measured along X. + const { gap, near, far } = intervalGap(movingAABB.minX, movingAABB.maxX, cb.minX, cb.maxX) + return { ...g, from: { x: near, z: g.coord }, to: { x: far, z: g.coord }, distance: gap } + }) +} diff --git a/packages/core/src/services/alignment.test.ts b/packages/core/src/services/alignment.test.ts index 3fa8aaaa6..4bfcaab88 100644 --- a/packages/core/src/services/alignment.test.ts +++ b/packages/core/src/services/alignment.test.ts @@ -1,10 +1,14 @@ import { describe, expect, test } from 'bun:test' -import { type AlignmentAnchor, bboxAnchors, resolveAlignment } from './alignment' +import { type AlignmentAnchor, bboxAnchors, resolveAlignment, resolvePointSnap } from './alignment' function center(nodeId: string, x: number, z: number): AlignmentAnchor { return { nodeId, kind: 'center', x, z } } +function corner(nodeId: string, x: number, z: number): AlignmentAnchor { + return { nodeId, kind: 'corner', x, z } +} + describe('resolveAlignment', () => { test('returns empty when no candidates within threshold', () => { const result = resolveAlignment({ @@ -72,6 +76,52 @@ describe('resolveAlignment', () => { }) }) +describe('resolvePointSnap', () => { + test('no match when only one axis is within threshold (collinear, not coincident)', () => { + // Shares X (Δx = 0.02) but Δz = 5 — "along the line", not a real point. + const result = resolvePointSnap({ + moving: [corner('m', 0.02, 5)], + candidates: [corner('a', 0, 0)], + threshold: 0.1, + }) + expect(result).toBeNull() + }) + + test('matches when a candidate is within threshold on BOTH axes', () => { + const result = resolvePointSnap({ + moving: [corner('m', 0.03, 0.04)], + candidates: [corner('a', 0, 0)], + threshold: 0.1, + }) + expect(result).not.toBeNull() + expect(result?.snap.dx).toBeCloseTo(-0.03, 10) + expect(result?.snap.dz).toBeCloseTo(-0.04, 10) + // Degenerate point guide at the candidate — renders as a dot. + expect(result?.guide.from).toEqual({ x: 0, z: 0 }) + expect(result?.guide.to).toEqual({ x: 0, z: 0 }) + expect(result?.guide.distance).toBe(0) + expect(result?.guide.candidateNodeId).toBe('a') + }) + + test('picks the closest coincident candidate', () => { + const result = resolvePointSnap({ + moving: [corner('m', 0, 0)], + candidates: [corner('far', 0.09, 0.09), corner('near', 0.02, 0.01)], + threshold: 0.1, + }) + expect(result?.guide.candidateNodeId).toBe('near') + }) + + test('threshold = 0 disables snapping', () => { + const result = resolvePointSnap({ + moving: [corner('m', 0, 0)], + candidates: [corner('a', 0, 0)], + threshold: 0, + }) + expect(result).toBeNull() + }) +}) + describe('bboxAnchors', () => { test('returns 9 anchors with correct kinds and positions', () => { const anchors = bboxAnchors('node', 0, 0, 2, 4) diff --git a/packages/core/src/services/alignment.ts b/packages/core/src/services/alignment.ts index ce4f8ba66..14278bdca 100644 --- a/packages/core/src/services/alignment.ts +++ b/packages/core/src/services/alignment.ts @@ -143,6 +143,73 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment return { guides, snap: { dx: dxSnap, dz: dzSnap } } } +export type ResolvePointSnapInput = { + /** Anchors of the moving node at its proposed (pre-snap) location. */ + moving: readonly AlignmentAnchor[] + /** Candidate anchors from nearby static objects. */ + candidates: readonly AlignmentAnchor[] + /** Max |Δ| (meters) on EACH axis for two anchors to count as coincident. */ + threshold: number +} + +export type ResolvePointSnapResult = { + /** Delta to add to the moving node so the matched anchor lands exactly on + * the candidate point. */ + snap: { dx: number; dz: number } + /** A degenerate guide marking the coincident point — renders as a single + * dot (no line / distance), since alignment here is point-to-point, not + * along an axis. */ + guide: AlignmentGuide +} | null + +/** + * Point-coincidence snap. Unlike {@link resolveAlignment} (which matches a + * single shared axis and draws a line to a possibly-distant object), this + * fires ONLY when a moving anchor lands within `threshold` of a candidate + * anchor on BOTH axes — i.e. the moving point reaches a real anchor point + * (corner / endpoint). Picks the closest such pair and snaps onto it. + * + * Returns `null` when no anchor pair coincides. + */ +export function resolvePointSnap(input: ResolvePointSnapInput): ResolvePointSnapResult { + const { moving, candidates, threshold } = input + if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return null + + let best: { + score: number + dx: number + dz: number + m: AlignmentAnchor + c: AlignmentAnchor + } | null = null + for (const m of moving) { + for (const c of candidates) { + const dx = c.x - m.x + const dz = c.z - m.z + if (Math.abs(dx) > threshold || Math.abs(dz) > threshold) continue + const score = Math.hypot(dx, dz) + if (best === null || score < best.score) best = { score, dx, dz, m, c } + } + } + + if (!best) return null + const x = best.c.x + const z = best.c.z + return { + snap: { dx: best.dx, dz: best.dz }, + guide: { + axis: 'x', + coord: x, + from: { x, z }, + to: { x, z }, + movingAnchorKind: best.m.kind, + candidateAnchorKind: best.c.kind, + candidateNodeId: best.c.nodeId, + distance: 0, + }, + } +} + // ─── Anchor extractors (pure) ───────────────────────────────────────── /** @@ -174,3 +241,23 @@ export function bboxAnchors( { nodeId, kind: 'center', x: cx, z: cz }, ] } + +/** + * The 4 corner anchors of a bbox — edges only, no edge-midpoints or center. + * Used where alignment should lock to an object's edges (left/right/front/ + * back), never its centreline. + */ +export function bboxCornerAnchors( + nodeId: string, + minX: number, + minZ: number, + maxX: number, + maxZ: number, +): AlignmentAnchor[] { + return [ + { nodeId, kind: 'corner', x: minX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: maxZ }, + { nodeId, kind: 'corner', x: minX, z: maxZ }, + ] +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index bb0cffe89..32fc3d226 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -4,10 +4,26 @@ export { type AlignmentGuideAxis, type AnchorKind, bboxAnchors, - resolveAlignment, + bboxCornerAnchors, type ResolveAlignmentInput, type ResolveAlignmentResult, + type ResolvePointSnapInput, + type ResolvePointSnapResult, + resolveAlignment, + resolvePointSnap, } from './alignment' +export { + collectAlignmentCandidates, + collectFloorFootprints, + type FootprintAABB, + footprintAABB, + footprintAABBAt, + footprintAABBFrom, + footprintAnchors, + movingFootprintAnchors, + refineGuidesToGap, + wallSegmentAnchors, +} from './alignment-anchors' export { createDragSession, type DragSession, diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx new file mode 100644 index 000000000..13ec6cffd --- /dev/null +++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx @@ -0,0 +1,143 @@ +'use client' + +import { type AlignmentGuide, useAlignmentGuides } from '@pascal-app/core' +import { Html } from '@react-three/drei' +import { memo, useMemo } from 'react' +import { BoxGeometry, CircleGeometry } from 'three' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' + +/** + * Figma-style alignment guides for the 3D editor — the spatial twin of + * `FloorplanAlignmentGuideLayer`. Subscribes to the shared + * `useAlignmentGuides` store (published by the move / placement / wall tools + * during a drag) and draws each guide as a dashed ribbon on the floor with a + * flat circular marker at each endpoint and a distance pill. + * + * The dashes + dots lie flat on the floor plane (XZ) — a ground ribbon, like + * the design reference — so they're real 3D geometry, not screen billboards. + * Only the distance pill is screen-space (``). + * + * Guide coordinates are XZ meters in the building-local frame; this layer is + * mounted inside ToolManager's building-local group so they render at the + * right world position (and line up with the cursor). + */ + +const LINE_COLOR = 0x81_8c_f8 // indigo-400 — matches the editor's selection accent (box-select / wall highlights) +const PILL_COLOR = '#6366f1' // indigo-500 — same hue, darker for white-text contrast +const GUIDE_Y = 0.03 // small lift so guides read above the floor grid +const DASH_LEN = 0.18 // world-meter dash length +const DASH_GAP = 0.12 // world-meter gap between dashes +const LINE_WIDTH = 0.06 // world-meter ribbon thickness +const DOT_RADIUS = 0.11 // world-meter radius of the endpoint markers +const MAX_DASHES = 80 // cap so a very long guide can't spawn thousands of quads + +// Shared resources — one violet material + unit geometries scaled per +// instance, so guide churn during a drag doesn't rebuild GPU buffers. +const guideMaterial = new MeshBasicNodeMaterial({ + color: LINE_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, +}) +const DASH_GEOMETRY = new BoxGeometry(1, 1, 1) +const DOT_GEOMETRY = new CircleGeometry(1, 24) + +type Vec3 = [number, number, number] + +export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() { + const guides = useAlignmentGuides((s) => s.guides) + if (guides.length === 0) return null + return ( + <> + {guides.map((guide, i) => ( + + ))} + + ) +}) + +function GuideLine({ guide }: { guide: AlignmentGuide }) { + const { x: fx, z: fz } = guide.from + const { x: tx, z: tz } = guide.to + const distLabel = formatMeters(guide.distance) + + // Lay out the dash centres along the from→to direction. The ribbon + // stretches the dash period up if the line is long enough to exceed the + // dash cap, so it always reads as a continuous dashed line. + const { dashes, angleY } = useMemo(() => { + const dx = tx - fx + const dz = tz - fz + const length = Math.hypot(dx, dz) + const angle = -Math.atan2(dz, dx) + if (length < 1e-4) return { dashes: [] as Vec3[], angleY: angle } + const ux = dx / length + const uz = dz / length + const period = Math.max(DASH_LEN + DASH_GAP, length / MAX_DASHES) + const centres: Vec3[] = [] + for (let d = period / 2; d - DASH_LEN / 2 <= length; d += period) { + centres.push([fx + ux * d, GUIDE_Y, fz + uz * d]) + } + return { dashes: centres, angleY: angle } + }, [fx, fz, tx, tz]) + + const mid: Vec3 = [(fx + tx) / 2, GUIDE_Y, (fz + tz) / 2] + + return ( + <> + {dashes.map((centre, i) => ( + + ))} + + + {guide.distance > 1e-4 && ( + +
+ {distLabel} +
+ + )} + + ) +} + +/** Flat circular marker lying on the floor plane at a guide endpoint. */ +function Dot({ position }: { position: Vec3 }) { + return ( + + ) +} + +function formatMeters(meters: number): string { + // Sub-centimetre = "0"; otherwise up to 2 decimals, trimmed. Matches the + // 2D floor-plan guide layer's pill formatting. + if (meters < 0.005) return '0' + const fixed = meters.toFixed(2) + return `${fixed.replace(/\.?0+$/, '')}m` +} 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 872446408..4d037ae29 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1,15 +1,24 @@ import type { AssetInput } from '@pascal-app/core' import { + type AnyNode, type AnyNodeId, type CeilingEvent, + collectFloorFootprints, emitter, + type FootprintAABB, + footprintAABBAt, + footprintAnchors, type GridEvent, getScaledDimensions, type ItemEvent, + movingFootprintAnchors, + refineGuidesToGap, + resolveAlignment, resolveLevelId, type ShelfEvent, sceneRegistry, spatialGridManager, + useAlignmentGuides, useLiveTransforms, useScene, useSpatialQuery, @@ -50,6 +59,10 @@ import type { DraftNodeHandle } from './use-draft-node' const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] +/** Figma-style alignment-snap threshold (meters), matching the 2D + * floor-plan overlay and the 3D registry move tool. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + function formatMeasurement(value: number, unit: 'metric' | 'imperial') { if (unit === 'imperial') { const feet = value * 3.280_84 @@ -446,6 +459,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } + // Lazily-gathered alignment footprints — every OTHER floor-placed node's + // XZ AABB, excluding the draft. Computed on the first floor move (once + // the draft id exists) and reused for the rest of the drag; the scene + // graph is stable during placement. Coords are building-local, matching + // the draft's grid position and the guide layer's frame. The AABBs drive + // the nearest-edge gap; their (corner) anchors feed the resolver. + let alignmentFootprints: Map | null = null + // Reset placement state placementState.current = configRef.current.initialState ?? { surface: 'floor', @@ -497,6 +518,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const applyTransition = (result: TransitionResult) => { + // Alignment guides are floor-only; clear them when the cursor moves + // onto a wall / ceiling / item surface (only those paths call this). + useAlignmentGuides.getState().clear() Object.assign(placementState.current, result.stateUpdate) gridPosition.current.set(...result.gridPosition) @@ -600,35 +624,82 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const result = floorStrategy.move(getContext(), event) if (!result) return + // Figma-style alignment snap layered on top of the floor strategy's + // grid snap: when the draft's edge lines up (on X or Z) with another + // item's edge, snap and publish a guide (line + nearest-edge distance). + // The delta is applied to BOTH the grid and cursor positions below. + // Alt bypasses. + const draft = draftNode.current + let alignX = 0 + let alignZ = 0 + const bypassAlign = event.nativeEvent?.altKey === true + if (!bypassAlign && draft) { + alignmentFootprints ??= collectFloorFootprints(useScene.getState().nodes, draft.id) + const draftNodeRef = draft as unknown as AnyNode + const rotationY = cursorGroupRef.current.rotation.y + const ar = resolveAlignment({ + moving: movingFootprintAnchors( + draftNodeRef, + result.gridPosition[0], + result.gridPosition[2], + rotationY, + ), + candidates: footprintAnchors(alignmentFootprints), + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (ar.snap) { + alignX = ar.snap.dx + alignZ = ar.snap.dz + } + const movingAABB = footprintAABBAt( + draftNodeRef, + result.gridPosition[0] + alignX, + result.gridPosition[2] + alignZ, + rotationY, + ) + useAlignmentGuides + .getState() + .set( + movingAABB ? refineGuidesToGap(ar.guides, movingAABB, alignmentFootprints) : ar.guides, + ) + } else { + useAlignmentGuides.getState().clear() + } + + const gridPos: [number, number, number] = [ + result.gridPosition[0] + alignX, + result.gridPosition[1], + result.gridPosition[2] + alignZ, + ] + const cursorPos: [number, number, number] = [ + result.cursorPosition[0] + alignX, + result.cursorPosition[1], + result.cursorPosition[2] + alignZ, + ] + // Play snap sound when grid position changes if ( previousGridPos && - (result.gridPosition[0] !== previousGridPos[0] || - result.gridPosition[2] !== previousGridPos[2]) + (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2]) ) { sfxEmitter.emit('sfx:grid-snap') } - previousGridPos = [...result.gridPosition] - gridPosition.current.set(...result.gridPosition) - cursorGroupRef.current.position.set( - result.cursorPosition[0], - result.cursorPosition[1], - result.cursorPosition[2], - ) + previousGridPos = [...gridPos] + gridPosition.current.set(...gridPos) + cursorGroupRef.current.position.set(cursorPos[0], cursorPos[1], cursorPos[2]) // Floor items only rotate on Y; keep the preview box (and the live // transform the 2D floorplan mirrors) aligned with the draft's // rotation. Without this the box stays at its seed rotation until a // manual R/T, so a moved already-rotated item shows an axis-aligned box. cursorGroupRef.current.rotation.y = result.cursorRotationY - const draft = draftNode.current - if (draft) draft.position = result.gridPosition + if (draft) draft.position = gridPos // Publish live transform for 2D floorplan if (draft) { useLiveTransforms.getState().set(draft.id, { - position: result.gridPosition, + position: gridPos, rotation: cursorGroupRef.current.rotation.y, }) } @@ -637,6 +708,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onGridClick = (event: GridEvent) => { + // Drop alignment guides on click — the move commits (guides done) or + // placement re-arms (the next move republishes them). + useAlignmentGuides.getState().clear() const result = floorStrategy.click(getContext(), event, getActiveValidators()) if (!result) return @@ -1435,6 +1509,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- tool:cancel (Escape / programmatic) ---- const onCancel = () => { + useAlignmentGuides.getState().clear() if (configRef.current.onCancel) { configRef.current.onCancel() } @@ -1512,6 +1587,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return () => { tearingDown = true unsubDraftWatch() + useAlignmentGuides.getState().clear() // Clear live transform for any remaining draft if (draftNode.current) { useLiveTransforms.getState().clear(draftNode.current.id) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 977bb1518..f49a3a3a6 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -5,12 +5,19 @@ import '../../../three-types' import { type AnyNode, type AnyNodeId, + collectFloorFootprints, type EventSuffix, emitter, + footprintAABBAt, + footprintAnchors, type GridEvent, + movingFootprintAnchors, type NodeEvent, nodeRegistry, + refineGuidesToGap, + resolveAlignment, sceneRegistry, + useAlignmentGuides, useLiveTransforms, useScene, } from '@pascal-app/core' @@ -30,6 +37,11 @@ const snapToGridStep = (value: number) => { /** 90° steps, matching the GLB item placement rotation. */ const ROTATION_STEP = Math.PI / 2 +/** Figma-style alignment-snap threshold (meters), matching the 2D + * floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull + * without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + /** * Generic move tool for any registry-backed kind. * @@ -165,9 +177,47 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { }) } + // Static alignment footprints — every OTHER floor-placed node's XZ AABB, + // gathered once at drag start (the scene graph is stable during an + // imperative move). Coords are building-local, the same frame as + // `event.localPosition` and the rendered cursor, so the guides the 3D + // layer draws line up with the cursor. The (corner) anchors feed the + // resolver; the AABBs feed the nearest-edge gap refinement. + const alignmentFootprints = collectFloorFootprints(useScene.getState().nodes, node.id) + const alignmentCandidates = footprintAnchors(alignmentFootprints) + const onGridMove = (event: GridEvent) => { - const x = snapToGridStep(event.localPosition[0]) - const z = snapToGridStep(event.localPosition[2]) + let x = snapToGridStep(event.localPosition[0]) + let z = snapToGridStep(event.localPosition[2]) + + // Figma-style alignment snap layered on top of grid snap: when the + // moving item's edge lines up (on X or Z) with another item's edge, + // snap and publish a guide (line + nearest-edge distance). Alt bypasses. + const bypass = event.nativeEvent?.altKey === true + if (!bypass && alignmentCandidates.length > 0) { + const result = resolveAlignment({ + moving: movingFootprintAnchors(node, x, z, rotationRef.current), + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + x += result.snap.dx + z += result.snap.dz + } + // Re-span guides to the gap between nearest footprint edges at the + // post-snap position rather than the matched anchor-to-anchor span. + const movingAABB = footprintAABBAt(node, x, z, rotationRef.current) + useAlignmentGuides + .getState() + .set( + movingAABB + ? refineGuidesToGap(result.guides, movingAABB, alignmentFootprints) + : result.guides, + ) + } else { + useAlignmentGuides.getState().clear() + } + hasMovedRef.current = true setCursorPosition([x, 0, z]) lastCursorRef.current = [x, 0, z] @@ -255,6 +305,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Now safe to clear — node.position is already the new value, so // `ParametricNodeRenderer`'s next render lands at `[x, 0, z]`. useLiveTransforms.getState().clear(node.id) + useAlignmentGuides.getState().clear() sfxEmitter.emit('sfx:item-place') exitMoveMode() @@ -312,6 +363,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { m.rotation.y = originalRotationY } useLiveTransforms.getState().clear(node.id) + useAlignmentGuides.getState().clear() useScene.temporal.getState().resume() markToolCancelConsumed() exitMoveMode() @@ -330,6 +382,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Restore the moved meshes' raycast so they're hoverable / selectable // again after the drag ends. for (const restore of restoreRaycasts) restore() + // Drop any alignment guides this drag published — covers Esc / mid-drag + // unmount / commit paths uniformly. + useAlignmentGuides.getState().clear() if (!committed) { sceneRegistry.nodes .get(node.id) diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 69633b5f0..8d515bd65 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -9,6 +9,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' +import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' import { ColumnTool } from './column/column-tool' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' @@ -265,6 +266,10 @@ export const ToolManager: React.FC = () => { {!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? ( ) : null} + {/* Figma-style alignment guides published by the move / placement + tools above. Lives inside the building-local group so the + building-local guide coords render at the right world position. */} + ) diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index 6ba3e0bad..c04d7aa49 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -1,10 +1,16 @@ import { + type AlignmentAnchor, type AnyNode, type AnyNodeId, type DragAction, type FenceNode, + type FootprintAABB, + refineGuidesToGap, + resolveAlignment, + useAlignmentGuides, useScene, type WallNode, + wallSegmentAnchors, } from '@pascal-app/core' import { type FencePlanPoint, @@ -43,6 +49,19 @@ import { const LINKED_FENCE_ENDPOINT_EPSILON = 0.025 +/** Figma-style alignment-snap threshold (meters), matching the wall / item + * tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + +function segmentAABB(start: FencePlanPoint, end: FencePlanPoint): FootprintAABB { + return { + minX: Math.min(start[0], end[0]), + maxX: Math.max(start[0], end[0]), + minZ: Math.min(start[1], end[1]), + maxZ: Math.max(start[1], end[1]), + } +} + function samePoint(a: FencePlanPoint, b: FencePlanPoint): boolean { return ( Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON && @@ -67,6 +86,11 @@ export type MoveFenceEndpointCtx = { linkedOriginals: LinkedFenceSnapshot[] levelWalls: WallNode[] levelFences: FenceNode[] + /** Alignment anchors + footprint AABBs of every OTHER wall / fence on the + * level (building-local). Anchors feed the resolver; AABBs let the guide + * re-span to the nearest edge. */ + alignCandidates: AlignmentAnchor[] + alignFootprints: Map } export type MoveFenceEndpointDraft = { @@ -132,6 +156,16 @@ export const moveFenceEndpointDragAction: DragAction f.id !== fence.id), + ] + const alignCandidates = alignSegments.flatMap((s) => wallSegmentAnchors(s.id, s.start, s.end)) + const alignFootprints = new Map( + alignSegments.map((s) => [s.id, segmentAABB(s.start, s.end)]), + ) + return { fenceId: fence.id as AnyNodeId, endpoint, @@ -143,6 +177,8 @@ export const moveFenceEndpointDragAction: DragAction 0) { + const ar = resolveAlignment({ + moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }], + candidates: ctx.alignCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (ar.snap) { + aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz] + } + const movingAABB: FootprintAABB = { + minX: aligned[0], + maxX: aligned[0], + minZ: aligned[1], + maxZ: aligned[1], + } + useAlignmentGuides + .getState() + .set(refineGuidesToGap(ar.guides, movingAABB, ctx.alignFootprints)) + } + + const nextStart = ctx.endpoint === 'start' ? aligned : ctx.fixedPoint + const nextEnd = ctx.endpoint === 'end' ? aligned : ctx.fixedPoint const detached = modifiers.alt const linkedUpdates = detached ? [] - : linkedCascade(ctx.linkedOriginals, ctx.originalMovingPoint, snapped) + : linkedCascade(ctx.linkedOriginals, ctx.originalMovingPoint, aligned) return { - movingPoint: snapped, + movingPoint: aligned, start: nextStart, end: nextEnd, linkedUpdates, @@ -190,6 +251,7 @@ export const moveFenceEndpointDragAction: DragAction { + useAlignmentGuides.getState().clear() // Min-length rejection still matters — too-short fence is invalid // and should bounce back via the cancel path (snapshot restore). // But the "no-change" rejection is removed: see @@ -220,7 +282,8 @@ export const moveFenceEndpointDragAction: DragAction { - // No-op — createDragSession.cancel() calls scene.restoreAll() + useAlignmentGuides.getState().clear() + // No-op otherwise — createDragSession.cancel() calls scene.restoreAll() // which puts every touched node back via the snapshot. }, } diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index e13101f06..6750289bb 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -1,6 +1,12 @@ 'use client' -import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core' +import { + type FenceNode, + getWallCurveLength, + useAlignmentGuides, + useScene, + type WallNode, +} from '@pascal-app/core' import { CursorSphere, type FencePlanPoint, @@ -153,6 +159,10 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ], ) + // Safety net: drop any alignment guides if the tool unmounts without the + // action's commit / cancel running (e.g. abrupt teardown). + useEffect(() => () => useAlignmentGuides.getState().clear(), []) + // Window-level keystate for the detach badge — independent of grid // event modifiers so the badge can toggle without a pointer move. useEffect(() => { diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index eb36b1d20..f02d295bb 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -4,13 +4,18 @@ import { type AnyNodeId, DEFAULT_WALL_HEIGHT, emitter, + type FootprintAABB, type GridEvent, getWallCurveLength, getWallThickness, pauseSceneHistory, + refineGuidesToGap, + resolveAlignment, resumeSceneHistory, + useAlignmentGuides, useScene, type WallNode, + wallSegmentAnchors, } from '@pascal-app/core' import { CursorSphere, @@ -43,6 +48,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' * `wall/definition.ts`. Editor state trigger is * `useEditor.movingWallEndpoint`. */ +/** Figma-style alignment-snap threshold (meters), matching the item move / + * placement tools. 8 cm gives a magnetic pull without fighting grid snap. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + function samePoint(a: WallPlanPoint, b: WallPlanPoint) { return a[0] === b[0] && a[1] === b[1] } @@ -207,6 +216,35 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), ) + // Alignment candidates — endpoints + midpoints of every OTHER wall AND + // fence on this level (both share the start/end segment shape), gathered + // once (the set is stable during the drag). Coords are building-local, + // the same frame as the cursor and the 3D guide layer, so the published + // marker lines up. + const parentId = target.wall.parentId ?? null + const alignSegments: { id: string; start: WallPlanPoint; end: WallPlanPoint }[] = [] + for (const segment of Object.values(useScene.getState().nodes)) { + if (!segment || segment.id === nodeId) continue + if ((segment.parentId ?? null) !== parentId) continue + if (segment.type === 'wall' || segment.type === 'fence') { + alignSegments.push({ id: segment.id, start: segment.start, end: segment.end }) + } + } + const wallAlignmentCandidates = alignSegments.flatMap((segment) => + wallSegmentAnchors(segment.id, segment.start, segment.end), + ) + const wallFootprints = new Map( + alignSegments.map((segment) => [ + segment.id, + { + minX: Math.min(segment.start[0], segment.end[0]), + maxX: Math.max(segment.start[0], segment.end[0]), + minZ: Math.min(segment.start[1], segment.end[1]), + maxZ: Math.max(segment.start[1], segment.end[1]), + }, + ]), + ) + pauseSceneHistory(useScene) let wasCommitted = false @@ -285,20 +323,44 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined, }) + // Figma-style alignment: nudge the dragged endpoint onto another wall / + // fence endpoint or midpoint axis when within threshold, and publish a + // guide (line + nearest-edge distance). Layered on top of the grid + + // corner snap above; Alt is reserved for corner-detach here. + let alignedPoint = snappedPoint + if (wallAlignmentCandidates.length > 0) { + const ar = resolveAlignment({ + moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }], + candidates: wallAlignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (ar.snap) { + alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz] + } + const movingAABB: FootprintAABB = { + minX: alignedPoint[0], + maxX: alignedPoint[0], + minZ: alignedPoint[1], + maxZ: alignedPoint[1], + } + useAlignmentGuides.getState().set(refineGuidesToGap(ar.guides, movingAABB, wallFootprints)) + } + if ( previousGridPosRef.current && - (snappedPoint[0] !== previousGridPosRef.current[0] || - snappedPoint[1] !== previousGridPosRef.current[1]) + (alignedPoint[0] !== previousGridPosRef.current[0] || + alignedPoint[1] !== previousGridPosRef.current[1]) ) { triggerSFX('sfx:grid-snap') } - previousGridPosRef.current = snappedPoint + previousGridPosRef.current = alignedPoint hasDraggedRef.current = true - applyPreview(snappedPoint, event.nativeEvent.altKey) + applyPreview(alignedPoint, event.nativeEvent.altKey) } const onPointerUp = () => { + useAlignmentGuides.getState().clear() // Press-release without drag: dismiss the tool without committing. if (!hasDraggedRef.current) { useViewer.getState().setSelection({ selectedIds: [nodeId] }) @@ -345,6 +407,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } const onCancel = () => { + useAlignmentGuides.getState().clear() restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) resumeSceneHistory(useScene) @@ -390,6 +453,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ window.addEventListener('blur', onWindowBlur) return () => { + useAlignmentGuides.getState().clear() if (!wasCommitted) { restoreOriginal(false) } From dcafb74d423946bf273730a18ba57d6454ae6781 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 3 Jun 2026 14:48:35 +0530 Subject: [PATCH 04/17] fix(editor): align guides snap to nearest real anchor, drop bbox re-span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3D alignment guide could place its end dot in empty space: a diagonal wall (or any rotated / non-rectangular object) has bounding-box corners that don't lie on the object, and `refineGuidesToGap` re-spanned the guide to exactly those AABB edges — so the dot floated "along the coordinate" rather than on the item. - `resolveAlignment` now tie-breaks to the candidate anchor NEAREST on the perpendicular axis (after the tightest axis match). Anchors are real points (corners / endpoints / midpoints), so the guide always connects to the closest actual point — which also yields the facing-edge gap distance. - All four producers (item move, item placement, wall + fence endpoints) now publish the raw resolver guides; the AABB nearest-edge re-span is gone. - Removed the now-dead `refineGuidesToGap` and `resolvePointSnap` helpers (and their tests / exports). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/services/alignment-anchors.test.ts | 46 -------- .../core/src/services/alignment-anchors.ts | 45 +------- packages/core/src/services/alignment.test.ts | 59 +++-------- packages/core/src/services/alignment.ts | 100 +++++------------- packages/core/src/services/index.ts | 4 - .../tools/item/use-placement-coordinator.tsx | 50 ++++----- .../registry/move-registry-node-tool.tsx | 34 ++---- .../nodes/src/fence/actions/move-endpoint.ts | 34 +----- .../nodes/src/wall/move-endpoint-tool.tsx | 27 +---- 9 files changed, 76 insertions(+), 323 deletions(-) diff --git a/packages/core/src/services/alignment-anchors.test.ts b/packages/core/src/services/alignment-anchors.test.ts index 8ce99b0cc..ff787713b 100644 --- a/packages/core/src/services/alignment-anchors.test.ts +++ b/packages/core/src/services/alignment-anchors.test.ts @@ -3,15 +3,12 @@ import { z } from 'zod' import { nodeRegistry, registerNode } from '../registry' import type { AnyNodeDefinition } from '../registry/types' import type { AnyNode } from '../schema/types' -import type { AlignmentGuide } from './alignment' import { collectAlignmentCandidates, collectFloorFootprints, - type FootprintAABB, footprintAABB, footprintAABBFrom, movingFootprintAnchors, - refineGuidesToGap, wallSegmentAnchors, } from './alignment-anchors' @@ -175,49 +172,6 @@ describe('collectFloorFootprints', () => { }) }) -describe('refineGuidesToGap', () => { - const guideX = (coord: number, candidateNodeId: string): AlignmentGuide => ({ - axis: 'x', - coord, - from: { x: coord, z: 0 }, - to: { x: coord, z: 0 }, - movingAnchorKind: 'edge-mid', - candidateAnchorKind: 'edge-mid', - candidateNodeId, - distance: 0, - }) - - test('measures the gap between nearest facing edges, not anchor-to-anchor', () => { - // Moving sits past the candidate along Z; gap is moving.minZ − candidate.maxZ. - const moving: FootprintAABB = { minX: 0, minZ: 3, maxX: 2, maxZ: 5 } - const footprints = new Map([ - ['c', { minX: 0, minZ: 0, maxX: 2, maxZ: 2 }], - ]) - const [g] = refineGuidesToGap([guideX(1, 'c')], moving, footprints) - expect(g!.distance).toBeCloseTo(1, 10) // 3 − 2, not center-to-center (4) - expect(g!.from.z).toBeCloseTo(2, 10) // candidate near edge - expect(g!.to.z).toBeCloseTo(3, 10) // moving near edge - }) - - test('overlapping footprints have zero gap and span the union', () => { - const moving: FootprintAABB = { minX: 0, minZ: 1, maxX: 2, maxZ: 3 } - const footprints = new Map([ - ['c', { minX: 0, minZ: 0, maxX: 2, maxZ: 2 }], - ]) - const [g] = refineGuidesToGap([guideX(1, 'c')], moving, footprints) - expect(g!.distance).toBe(0) - expect(g!.from.z).toBeCloseTo(0, 10) - expect(g!.to.z).toBeCloseTo(3, 10) - }) - - test('passes guides through unchanged when the candidate is absent', () => { - const moving: FootprintAABB = { minX: 0, minZ: 0, maxX: 1, maxZ: 1 } - const original = guideX(1, 'missing') - const [g] = refineGuidesToGap([original], moving, new Map()) - expect(g).toEqual(original) - }) -}) - describe('wallSegmentAnchors', () => { test('returns both endpoints as corners and the chord midpoint as center', () => { const anchors = wallSegmentAnchors('w', [0, 0], [4, 2]) diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index ea4a0fa9c..dd10c0bed 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -14,7 +14,7 @@ import { nodeRegistry } from '../registry' import type { AnyNode } from '../schema/types' -import { type AlignmentAnchor, type AlignmentGuide, bboxCornerAnchors } from './alignment' +import { type AlignmentAnchor, bboxCornerAnchors } from './alignment' export type FootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number } @@ -168,46 +168,3 @@ export function wallSegmentAnchors( { nodeId: id, kind: 'center', x: (start[0] + end[0]) / 2, z: (start[1] + end[1]) / 2 }, ] } - -/** Nearest-edge gap between two 1-D intervals and the two facing edges that - * bound it. When the intervals overlap there is no gap: returns the union - * span so the alignment line still reads across both, with `gap` 0. */ -function intervalGap( - aMin: number, - aMax: number, - bMin: number, - bMax: number, -): { gap: number; near: number; far: number } { - if (aMin >= bMax) return { gap: aMin - bMax, near: bMax, far: aMin } // a after b - if (aMax <= bMin) return { gap: bMin - aMax, near: aMax, far: bMin } // a before b - return { gap: 0, near: Math.min(aMin, bMin), far: Math.max(aMax, bMax) } // overlap -} - -/** - * Rewrite resolver guides so each spans (and measures) the gap between the - * NEAREST facing edges of the moving and candidate footprints, rather than - * the matched anchor-to-anchor span — which could run to the far side of an - * item. The line stays on the matched axis (`guide.coord`); only its extent - * along the perpendicular axis and its `distance` change. - * - * `movingAABB` should be the moving footprint at its post-snap position. - * Guides whose candidate isn't in `footprints` pass through unchanged. - */ -export function refineGuidesToGap( - guides: readonly AlignmentGuide[], - movingAABB: FootprintAABB, - footprints: ReadonlyMap, -): AlignmentGuide[] { - return guides.map((g) => { - const cb = footprints.get(g.candidateNodeId) - if (!cb) return g - if (g.axis === 'x') { - // Shared X = g.coord; gap measured along Z. - const { gap, near, far } = intervalGap(movingAABB.minZ, movingAABB.maxZ, cb.minZ, cb.maxZ) - return { ...g, from: { x: g.coord, z: near }, to: { x: g.coord, z: far }, distance: gap } - } - // Shared Z = g.coord; gap measured along X. - const { gap, near, far } = intervalGap(movingAABB.minX, movingAABB.maxX, cb.minX, cb.maxX) - return { ...g, from: { x: near, z: g.coord }, to: { x: far, z: g.coord }, distance: gap } - }) -} diff --git a/packages/core/src/services/alignment.test.ts b/packages/core/src/services/alignment.test.ts index 4bfcaab88..7eb86e128 100644 --- a/packages/core/src/services/alignment.test.ts +++ b/packages/core/src/services/alignment.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { type AlignmentAnchor, bboxAnchors, resolveAlignment, resolvePointSnap } from './alignment' +import { type AlignmentAnchor, bboxAnchors, resolveAlignment } from './alignment' function center(nodeId: string, x: number, z: number): AlignmentAnchor { return { nodeId, kind: 'center', x, z } @@ -55,6 +55,17 @@ describe('resolveAlignment', () => { expect(result.guides[0]!.candidateNodeId).toBe('b') }) + test('ties on the matched axis break toward the nearest perpendicular anchor', () => { + const result = resolveAlignment({ + moving: [corner('m', 0.02, 4)], + candidates: [corner('far', 0, 0), corner('near', 0, 5)], + threshold: 0.1, + }) + // Both share X (Δx = 0.02); 'near' (z=5) is closer to the moving z=4 than + // 'far' (z=0), so the guide connects to the nearest real anchor. + expect(result.guides[0]!.candidateNodeId).toBe('near') + }) + test('threshold = 0 disables alignment', () => { const result = resolveAlignment({ moving: [center('m', 0, 0)], @@ -76,52 +87,6 @@ describe('resolveAlignment', () => { }) }) -describe('resolvePointSnap', () => { - test('no match when only one axis is within threshold (collinear, not coincident)', () => { - // Shares X (Δx = 0.02) but Δz = 5 — "along the line", not a real point. - const result = resolvePointSnap({ - moving: [corner('m', 0.02, 5)], - candidates: [corner('a', 0, 0)], - threshold: 0.1, - }) - expect(result).toBeNull() - }) - - test('matches when a candidate is within threshold on BOTH axes', () => { - const result = resolvePointSnap({ - moving: [corner('m', 0.03, 0.04)], - candidates: [corner('a', 0, 0)], - threshold: 0.1, - }) - expect(result).not.toBeNull() - expect(result?.snap.dx).toBeCloseTo(-0.03, 10) - expect(result?.snap.dz).toBeCloseTo(-0.04, 10) - // Degenerate point guide at the candidate — renders as a dot. - expect(result?.guide.from).toEqual({ x: 0, z: 0 }) - expect(result?.guide.to).toEqual({ x: 0, z: 0 }) - expect(result?.guide.distance).toBe(0) - expect(result?.guide.candidateNodeId).toBe('a') - }) - - test('picks the closest coincident candidate', () => { - const result = resolvePointSnap({ - moving: [corner('m', 0, 0)], - candidates: [corner('far', 0.09, 0.09), corner('near', 0.02, 0.01)], - threshold: 0.1, - }) - expect(result?.guide.candidateNodeId).toBe('near') - }) - - test('threshold = 0 disables snapping', () => { - const result = resolvePointSnap({ - moving: [corner('m', 0, 0)], - candidates: [corner('a', 0, 0)], - threshold: 0, - }) - expect(result).toBeNull() - }) -}) - describe('bboxAnchors', () => { test('returns 9 anchors with correct kinds and positions', () => { const anchors = bboxAnchors('node', 0, 0, 2, 4) diff --git a/packages/core/src/services/alignment.ts b/packages/core/src/services/alignment.ts index 14278bdca..408e64afa 100644 --- a/packages/core/src/services/alignment.ts +++ b/packages/core/src/services/alignment.ts @@ -79,11 +79,20 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment const { moving, candidates, threshold } = input if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return EMPTY - // Best match per axis: smallest |Δ| across all (moving, candidate) pairs. - // Tie-break by candidate anchor kind priority (center > edge-mid > corner) - // so visually meaningful matches win when |Δ| is equal. - let bestX: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null - let bestZ: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null + // Best match per axis: smallest |Δ| on the matched axis (tightest + // alignment), then — crucially — tie-break to the candidate anchor NEAREST + // on the perpendicular axis. Anchors are real points (corners / endpoints / + // midpoints), so the guide always connects to the closest actual point of + // the candidate, never a far one that merely shares the same coordinate. + type Best = { + delta: number + primary: number + perp: number + m: AlignmentAnchor + c: AlignmentAnchor + } + let bestX: Best | null = null + let bestZ: Best | null = null for (const m of moving) { for (const c of candidates) { @@ -91,11 +100,17 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment const dz = c.z - m.z const adx = Math.abs(dx) const adz = Math.abs(dz) - if (adx <= threshold && (bestX === null || adx < Math.abs(bestX.delta))) { - bestX = { delta: dx, m, c } + if ( + adx <= threshold && + (bestX === null || adx < bestX.primary || (adx === bestX.primary && adz < bestX.perp)) + ) { + bestX = { delta: dx, primary: adx, perp: adz, m, c } } - if (adz <= threshold && (bestZ === null || adz < Math.abs(bestZ.delta))) { - bestZ = { delta: dz, m, c } + if ( + adz <= threshold && + (bestZ === null || adz < bestZ.primary || (adz === bestZ.primary && adx < bestZ.perp)) + ) { + bestZ = { delta: dz, primary: adz, perp: adx, m, c } } } } @@ -143,73 +158,6 @@ export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignment return { guides, snap: { dx: dxSnap, dz: dzSnap } } } -export type ResolvePointSnapInput = { - /** Anchors of the moving node at its proposed (pre-snap) location. */ - moving: readonly AlignmentAnchor[] - /** Candidate anchors from nearby static objects. */ - candidates: readonly AlignmentAnchor[] - /** Max |Δ| (meters) on EACH axis for two anchors to count as coincident. */ - threshold: number -} - -export type ResolvePointSnapResult = { - /** Delta to add to the moving node so the matched anchor lands exactly on - * the candidate point. */ - snap: { dx: number; dz: number } - /** A degenerate guide marking the coincident point — renders as a single - * dot (no line / distance), since alignment here is point-to-point, not - * along an axis. */ - guide: AlignmentGuide -} | null - -/** - * Point-coincidence snap. Unlike {@link resolveAlignment} (which matches a - * single shared axis and draws a line to a possibly-distant object), this - * fires ONLY when a moving anchor lands within `threshold` of a candidate - * anchor on BOTH axes — i.e. the moving point reaches a real anchor point - * (corner / endpoint). Picks the closest such pair and snaps onto it. - * - * Returns `null` when no anchor pair coincides. - */ -export function resolvePointSnap(input: ResolvePointSnapInput): ResolvePointSnapResult { - const { moving, candidates, threshold } = input - if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return null - - let best: { - score: number - dx: number - dz: number - m: AlignmentAnchor - c: AlignmentAnchor - } | null = null - for (const m of moving) { - for (const c of candidates) { - const dx = c.x - m.x - const dz = c.z - m.z - if (Math.abs(dx) > threshold || Math.abs(dz) > threshold) continue - const score = Math.hypot(dx, dz) - if (best === null || score < best.score) best = { score, dx, dz, m, c } - } - } - - if (!best) return null - const x = best.c.x - const z = best.c.z - return { - snap: { dx: best.dx, dz: best.dz }, - guide: { - axis: 'x', - coord: x, - from: { x, z }, - to: { x, z }, - movingAnchorKind: best.m.kind, - candidateAnchorKind: best.c.kind, - candidateNodeId: best.c.nodeId, - distance: 0, - }, - } -} - // ─── Anchor extractors (pure) ───────────────────────────────────────── /** diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 32fc3d226..6350043fb 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -7,10 +7,7 @@ export { bboxCornerAnchors, type ResolveAlignmentInput, type ResolveAlignmentResult, - type ResolvePointSnapInput, - type ResolvePointSnapResult, resolveAlignment, - resolvePointSnap, } from './alignment' export { collectAlignmentCandidates, @@ -21,7 +18,6 @@ export { footprintAABBFrom, footprintAnchors, movingFootprintAnchors, - refineGuidesToGap, wallSegmentAnchors, } from './alignment-anchors' export { 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 4d037ae29..6bb55be03 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1,18 +1,15 @@ import type { AssetInput } from '@pascal-app/core' import { + type AlignmentAnchor, type AnyNode, type AnyNodeId, type CeilingEvent, - collectFloorFootprints, + collectAlignmentCandidates, emitter, - type FootprintAABB, - footprintAABBAt, - footprintAnchors, type GridEvent, getScaledDimensions, type ItemEvent, movingFootprintAnchors, - refineGuidesToGap, resolveAlignment, resolveLevelId, type ShelfEvent, @@ -459,13 +456,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } - // Lazily-gathered alignment footprints — every OTHER floor-placed node's - // XZ AABB, excluding the draft. Computed on the first floor move (once - // the draft id exists) and reused for the rest of the drag; the scene - // graph is stable during placement. Coords are building-local, matching - // the draft's grid position and the guide layer's frame. The AABBs drive - // the nearest-edge gap; their (corner) anchors feed the resolver. - let alignmentFootprints: Map | null = null + // Lazily-gathered alignment candidates — the corner anchors of every + // OTHER floor-placed node, excluding the draft. Computed on the first + // floor move (once the draft id exists) and reused for the rest of the + // drag; the scene graph is stable during placement. Coords are + // building-local, matching the draft's grid position and the guide + // layer's frame. + let alignmentCandidates: AlignmentAnchor[] | null = null // Reset placement state placementState.current = configRef.current.initialState ?? { @@ -626,42 +623,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Figma-style alignment snap layered on top of the floor strategy's // grid snap: when the draft's edge lines up (on X or Z) with another - // item's edge, snap and publish a guide (line + nearest-edge distance). - // The delta is applied to BOTH the grid and cursor positions below. - // Alt bypasses. + // item's edge, snap and publish a guide. The guide connects to the + // nearest real corner of the candidate (resolver tie-break), so the dot + // always sits on an actual point. The delta is applied to BOTH the grid + // and cursor positions below. Alt bypasses. const draft = draftNode.current let alignX = 0 let alignZ = 0 const bypassAlign = event.nativeEvent?.altKey === true if (!bypassAlign && draft) { - alignmentFootprints ??= collectFloorFootprints(useScene.getState().nodes, draft.id) - const draftNodeRef = draft as unknown as AnyNode - const rotationY = cursorGroupRef.current.rotation.y + alignmentCandidates ??= collectAlignmentCandidates(useScene.getState().nodes, draft.id) const ar = resolveAlignment({ moving: movingFootprintAnchors( - draftNodeRef, + draft as unknown as AnyNode, result.gridPosition[0], result.gridPosition[2], - rotationY, + cursorGroupRef.current.rotation.y, ), - candidates: footprintAnchors(alignmentFootprints), + candidates: alignmentCandidates, threshold: ALIGNMENT_THRESHOLD_M, }) if (ar.snap) { alignX = ar.snap.dx alignZ = ar.snap.dz } - const movingAABB = footprintAABBAt( - draftNodeRef, - result.gridPosition[0] + alignX, - result.gridPosition[2] + alignZ, - rotationY, - ) - useAlignmentGuides - .getState() - .set( - movingAABB ? refineGuidesToGap(ar.guides, movingAABB, alignmentFootprints) : ar.guides, - ) + useAlignmentGuides.getState().set(ar.guides) } else { useAlignmentGuides.getState().clear() } diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index f49a3a3a6..bc773fe4a 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -5,16 +5,13 @@ import '../../../three-types' import { type AnyNode, type AnyNodeId, - collectFloorFootprints, + collectAlignmentCandidates, type EventSuffix, emitter, - footprintAABBAt, - footprintAnchors, type GridEvent, movingFootprintAnchors, type NodeEvent, nodeRegistry, - refineGuidesToGap, resolveAlignment, sceneRegistry, useAlignmentGuides, @@ -177,14 +174,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { }) } - // Static alignment footprints — every OTHER floor-placed node's XZ AABB, - // gathered once at drag start (the scene graph is stable during an - // imperative move). Coords are building-local, the same frame as - // `event.localPosition` and the rendered cursor, so the guides the 3D - // layer draws line up with the cursor. The (corner) anchors feed the - // resolver; the AABBs feed the nearest-edge gap refinement. - const alignmentFootprints = collectFloorFootprints(useScene.getState().nodes, node.id) - const alignmentCandidates = footprintAnchors(alignmentFootprints) + // Static alignment candidates — the corner anchors of every OTHER + // floor-placed node, gathered once at drag start (the scene graph is + // stable during an imperative move). Coords are building-local, the same + // frame as `event.localPosition` and the rendered cursor, so the guide + // dots line up with the cursor. + const alignmentCandidates = collectAlignmentCandidates(useScene.getState().nodes, node.id) const onGridMove = (event: GridEvent) => { let x = snapToGridStep(event.localPosition[0]) @@ -192,7 +187,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Figma-style alignment snap layered on top of grid snap: when the // moving item's edge lines up (on X or Z) with another item's edge, - // snap and publish a guide (line + nearest-edge distance). Alt bypasses. + // snap and publish a guide. The guide connects to the nearest real + // corner of the candidate (resolver tie-break), so the dot always sits + // on an actual point. Alt bypasses. const bypass = event.nativeEvent?.altKey === true if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignment({ @@ -204,16 +201,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { x += result.snap.dx z += result.snap.dz } - // Re-span guides to the gap between nearest footprint edges at the - // post-snap position rather than the matched anchor-to-anchor span. - const movingAABB = footprintAABBAt(node, x, z, rotationRef.current) - useAlignmentGuides - .getState() - .set( - movingAABB - ? refineGuidesToGap(result.guides, movingAABB, alignmentFootprints) - : result.guides, - ) + useAlignmentGuides.getState().set(result.guides) } else { useAlignmentGuides.getState().clear() } diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index c04d7aa49..ed108ccd8 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -4,8 +4,6 @@ import { type AnyNodeId, type DragAction, type FenceNode, - type FootprintAABB, - refineGuidesToGap, resolveAlignment, useAlignmentGuides, useScene, @@ -53,15 +51,6 @@ const LINKED_FENCE_ENDPOINT_EPSILON = 0.025 * tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 -function segmentAABB(start: FencePlanPoint, end: FencePlanPoint): FootprintAABB { - return { - minX: Math.min(start[0], end[0]), - maxX: Math.max(start[0], end[0]), - minZ: Math.min(start[1], end[1]), - maxZ: Math.max(start[1], end[1]), - } -} - function samePoint(a: FencePlanPoint, b: FencePlanPoint): boolean { return ( Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON && @@ -86,11 +75,9 @@ export type MoveFenceEndpointCtx = { linkedOriginals: LinkedFenceSnapshot[] levelWalls: WallNode[] levelFences: FenceNode[] - /** Alignment anchors + footprint AABBs of every OTHER wall / fence on the - * level (building-local). Anchors feed the resolver; AABBs let the guide - * re-span to the nearest edge. */ + /** Alignment anchors (endpoints + midpoints) of every OTHER wall / fence on + * the level (building-local), feeding the resolver. */ alignCandidates: AlignmentAnchor[] - alignFootprints: Map } export type MoveFenceEndpointDraft = { @@ -162,9 +149,6 @@ export const moveFenceEndpointDragAction: DragAction f.id !== fence.id), ] const alignCandidates = alignSegments.flatMap((s) => wallSegmentAnchors(s.id, s.start, s.end)) - const alignFootprints = new Map( - alignSegments.map((s) => [s.id, segmentAABB(s.start, s.end)]), - ) return { fenceId: fence.id as AnyNodeId, @@ -178,7 +162,6 @@ export const moveFenceEndpointDragAction: DragAction 0) { const ar = resolveAlignment({ @@ -208,15 +192,7 @@ export const moveFenceEndpointDragAction: DragAction = ({ const wallAlignmentCandidates = alignSegments.flatMap((segment) => wallSegmentAnchors(segment.id, segment.start, segment.end), ) - const wallFootprints = new Map( - alignSegments.map((segment) => [ - segment.id, - { - minX: Math.min(segment.start[0], segment.end[0]), - maxX: Math.max(segment.start[0], segment.end[0]), - minZ: Math.min(segment.start[1], segment.end[1]), - maxZ: Math.max(segment.start[1], segment.end[1]), - }, - ]), - ) pauseSceneHistory(useScene) let wasCommitted = false @@ -325,8 +312,10 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ // Figma-style alignment: nudge the dragged endpoint onto another wall / // fence endpoint or midpoint axis when within threshold, and publish a - // guide (line + nearest-edge distance). Layered on top of the grid + - // corner snap above; Alt is reserved for corner-detach here. + // guide. The resolver connects to the NEAREST real anchor of the + // candidate, so the dot always sits on an actual point (endpoint / + // midpoint), never an empty-space bbox corner. Layered on top of the + // grid + corner snap above; Alt is reserved for corner-detach here. let alignedPoint = snappedPoint if (wallAlignmentCandidates.length > 0) { const ar = resolveAlignment({ @@ -337,13 +326,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ if (ar.snap) { alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz] } - const movingAABB: FootprintAABB = { - minX: alignedPoint[0], - maxX: alignedPoint[0], - minZ: alignedPoint[1], - maxZ: alignedPoint[1], - } - useAlignmentGuides.getState().set(refineGuidesToGap(ar.guides, movingAABB, wallFootprints)) + useAlignmentGuides.getState().set(ar.guides) } if ( From 20a221c78fffc112a36a990762f199ecfc745988 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 3 Jun 2026 16:01:22 +0530 Subject: [PATCH 05/17] feat(editor): group move handle + shared group-transform core, move-tool polish Add a group-move gizmo alongside the existing group-rotate handle, both driven by a new shared `group-transform-shared` module (participant classification, group-box + corner math, connected wall/fence component expansion so attached structure transforms rigidly as one piece). - core: refactor alignment-anchors collection + tests, extend handle registry - editor: group-move-handle, group-transform-shared; rotate handle reuses them; node-arrow-handles gains click-swallow guard; box-select + placement tweaks - nodes: move-tool updates across ceiling/column/slab/roof/registry; door math and panel adjustments; item definition cleanup - nodes(fence): play `sfx:grid-snap` ticker on endpoint move, matching the wall endpoint tool (fixes missing audio feedback on fence side drag) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/registry/handles.ts | 21 +- .../src/services/alignment-anchors.test.ts | 92 +++--- .../core/src/services/alignment-anchors.ts | 102 ++++--- packages/core/src/services/index.ts | 6 +- .../components/editor/group-move-handle.tsx | 271 ++++++++++++++++++ .../components/editor/group-rotate-handle.tsx | 209 ++++++++------ .../editor/group-transform-shared.ts | 189 ++++++++++++ .../editor/src/components/editor/index.tsx | 2 + .../components/editor/node-arrow-handles.tsx | 90 +++++- .../tools/item/use-placement-coordinator.tsx | 36 ++- .../registry/move-registry-node-tool.tsx | 14 +- .../tools/select/box-select-tool.tsx | 18 ++ .../ui/action-menu/view-toggles.tsx | 1 + packages/editor/src/lib/editor-api.ts | 12 + packages/editor/src/store/use-editor.tsx | 12 +- packages/nodes/src/ceiling/move-tool.tsx | 38 ++- packages/nodes/src/column/move-tool.tsx | 51 +++- packages/nodes/src/door/definition.ts | 4 + packages/nodes/src/door/door-math.ts | 16 ++ packages/nodes/src/door/panel.tsx | 9 +- .../nodes/src/fence/actions/move-endpoint.ts | 11 +- .../nodes/src/fence/move-endpoint-tool.tsx | 15 +- packages/nodes/src/item/definition.ts | 65 ++--- packages/nodes/src/shared/move-roof-tool.tsx | 53 +++- packages/nodes/src/slab/move-tool.tsx | 38 ++- .../nodes/src/wall/move-endpoint-tool.tsx | 24 +- 26 files changed, 1115 insertions(+), 284 deletions(-) create mode 100644 packages/editor/src/components/editor/group-move-handle.tsx create mode 100644 packages/editor/src/components/editor/group-transform-shared.ts diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 9db7cabd2..f16f69a16 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -44,6 +44,12 @@ export type EditorApi = { * or curving state so the move starts from a clean slate. */ engageMove: (node: AnyNode) => void + /** + * Like {@link engageMove}, but for a press-drag gizmo: the move commits on + * pointer-release instead of waiting for a click, so the on-canvas move cross + * behaves as press-drag-release while still showing the placement preview. + */ + engageMoveDrag: (node: AnyNode) => void /** * Engage endpoint drag for kinds that own start / end anchors (walls, * fences). No-ops for kinds without endpoints. @@ -281,14 +287,25 @@ export type TapActionHandle = { * trigger the desired action. */ onActivate: (node: N, scene: SceneApi, editor: EditorApi) => void - /** Visual override; defaults to the standard chevron arrow. */ - shape?: 'arrow' | 'corner-picker' + /** + * Visual override; defaults to the standard chevron arrow. `'move-cross'` + * reuses the 4-way move cross — a tap-to-engage grip that hands the node to + * its move tool (via `onActivate`) instead of running the generic translate + * drag, so the move tool's own preview / ticker feedback shows up. + */ + shape?: 'arrow' | 'corner-picker' | 'move-cross' /** * Required when `shape: 'corner-picker'` — controls the dashed leader's * vertical extent. Pure callback so the descriptor doesn't need to * import 3D libs. */ nodeHeight?: (node: N) => number + /** + * `shape: 'move-cross'` only — tilts the flat cross to lie in the right + * plane. `'horizontal'` (default) leaves it flat on the floor; `'node-normal'` + * stands it up against the node's facing plane (a wall face). + */ + plane?: 'horizontal' | 'node-normal' portal?: HandlePortal cursor?: Cursor } diff --git a/packages/core/src/services/alignment-anchors.test.ts b/packages/core/src/services/alignment-anchors.test.ts index ff787713b..a9e1a6283 100644 --- a/packages/core/src/services/alignment-anchors.test.ts +++ b/packages/core/src/services/alignment-anchors.test.ts @@ -4,11 +4,11 @@ import { nodeRegistry, registerNode } from '../registry' import type { AnyNodeDefinition } from '../registry/types' import type { AnyNode } from '../schema/types' import { - collectAlignmentCandidates, - collectFloorFootprints, + collectAlignmentAnchors, footprintAABB, footprintAABBFrom, movingFootprintAnchors, + polygonAnchors, wallSegmentAnchors, } from './alignment-anchors' @@ -79,6 +79,13 @@ describe('footprintAABB', () => { expect(footprintAABB(node({ id: 'w1', type: 'wall', position: [0, 0, 0] }))).toBeNull() }) + test('derives an elevator footprint from its width / depth (no floorPlaced needed)', () => { + const aabb = footprintAABB( + node({ id: 'e1', type: 'elevator', position: [10, 0, 20], width: 2, depth: 4, rotation: 0 }), + ) + expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 }) + }) + test('returns null when the kind predicate excludes the node', () => { registerNode(floorPlacedDef('lamp', (n) => !(n as { attached?: boolean }).attached)) expect( @@ -90,26 +97,6 @@ describe('footprintAABB', () => { }) }) -describe('collectAlignmentCandidates', () => { - beforeEach(() => nodeRegistry._reset()) - - test('excludes the moving node and skips footprintless kinds', () => { - registerNode(floorPlacedDef('box')) - registerNode(plainDef('wall')) - const nodes = { - moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }), - other: node({ id: 'other', type: 'box', position: [5, 0, 5], dimensions: [1, 1, 1] }), - wall: node({ id: 'wall', type: 'wall', position: [2, 0, 2] }), - } - const anchors = collectAlignmentCandidates(nodes, 'moving') - // Only `other` contributes — 4 corner anchors (edges only), none from the - // moving node or the wall. - expect(anchors).toHaveLength(4) - expect(anchors.every((a) => a.nodeId === 'other')).toBe(true) - expect(anchors.every((a) => a.kind === 'corner')).toBe(true) - }) -}) - describe('movingFootprintAnchors', () => { beforeEach(() => nodeRegistry._reset()) @@ -155,23 +142,6 @@ describe('movingFootprintAnchors', () => { }) }) -describe('collectFloorFootprints', () => { - beforeEach(() => nodeRegistry._reset()) - - test('maps floor-placed nodes by id, excluding the moving node and plain kinds', () => { - registerNode(floorPlacedDef('box')) - registerNode(plainDef('wall')) - const nodes = { - moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }), - other: node({ id: 'other', type: 'box', position: [5, 0, 5], dimensions: [2, 1, 4] }), - wall: node({ id: 'wall', type: 'wall', position: [2, 0, 2] }), - } - const map = collectFloorFootprints(nodes, 'moving') - expect([...map.keys()]).toEqual(['other']) - expect(map.get('other')).toEqual({ minX: 4, minZ: 3, maxX: 6, maxZ: 7 }) - }) -}) - describe('wallSegmentAnchors', () => { test('returns both endpoints as corners and the chord midpoint as center', () => { const anchors = wallSegmentAnchors('w', [0, 0], [4, 2]) @@ -182,3 +152,47 @@ describe('wallSegmentAnchors', () => { ]) }) }) + +describe('polygonAnchors', () => { + test('returns each vertex as a corner anchor', () => { + expect( + polygonAnchors('s', [ + [0, 0], + [2, 0], + [2, 3], + ]), + ).toEqual([ + { nodeId: 's', kind: 'corner', x: 0, z: 0 }, + { nodeId: 's', kind: 'corner', x: 2, z: 0 }, + { nodeId: 's', kind: 'corner', x: 2, z: 3 }, + ]) + }) +}) + +describe('collectAlignmentAnchors', () => { + beforeEach(() => nodeRegistry._reset()) + + test('unions footprint corners, segment anchors and polygon vertices, excluding the moving node', () => { + registerNode(floorPlacedDef('box')) + const nodes = { + moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }), + box: node({ id: 'box', type: 'box', position: [5, 0, 5], dimensions: [2, 1, 2] }), + wall: node({ id: 'wall', type: 'wall', start: [0, 0], end: [4, 0] }), + slab: node({ + id: 'slab', + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + ], + }), + } + const anchors = collectAlignmentAnchors(nodes, 'moving') + const ids = anchors.map((a) => a.nodeId) + expect(ids).not.toContain('moving') + expect(ids.filter((id) => id === 'box')).toHaveLength(4) // corner anchors + expect(ids.filter((id) => id === 'wall')).toHaveLength(3) // endpoints + midpoint + expect(ids.filter((id) => id === 'slab')).toHaveLength(3) // polygon vertices + }) +}) diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index dd10c0bed..9b3c12402 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -65,9 +65,18 @@ function floorFootprint( node: AnyNode, ): { dimensions: [number, number, number]; rotation: [number, number, number] } | null { const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced - if (!floorPlaced) return null - if (floorPlaced.applies && !floorPlaced.applies(node)) return null - return floorPlaced.footprint(node) + if (floorPlaced) { + if (floorPlaced.applies && !floorPlaced.applies(node)) return null + return floorPlaced.footprint(node) + } + // Elevator isn't a `floorPlaced` kind (no slab-elevation coupling) but it + // does rest on the floor with a `width × depth` cab — give it a footprint + // so it aligns like other boxes (the registry move tool reads this). + if (node.type === 'elevator') { + const e = node as { width?: number; depth?: number; rotation?: number } + return { dimensions: [e.width ?? 1.6, 1, e.depth ?? 1.6], rotation: [0, e.rotation ?? 0, 0] } + } + return null } /** XZ footprint AABB of a floor-placed node at its current position, or @@ -93,48 +102,6 @@ export function footprintAABBAt( return footprintAABBFrom([x, 0, z], fp.dimensions, rotationY ?? fp.rotation[1] ?? 0) } -/** - * Footprint AABBs of every floor-placed node except `excludeId`, keyed by - * node id. The static pool both the resolver (via {@link footprintAnchors}) - * and the gap refinement (via {@link refineGuidesToGap}) draw from. Kinds - * without a footprint are omitted (bbox-anchors-only, matching v1). - */ -export function collectFloorFootprints( - nodes: Readonly>, - excludeId: string, -): Map { - const footprints = new Map() - for (const node of Object.values(nodes)) { - if (!node || node.id === excludeId) continue - const aabb = footprintAABB(node) - if (aabb) footprints.set(node.id, aabb) - } - return footprints -} - -/** Flatten a footprint map into the corner anchors the resolver matches. - * Corners only — alignment locks to item edges, never centrelines. */ -export function footprintAnchors( - footprints: ReadonlyMap, -): AlignmentAnchor[] { - const anchors: AlignmentAnchor[] = [] - for (const [id, b] of footprints) { - anchors.push(...bboxCornerAnchors(id, b.minX, b.minZ, b.maxX, b.maxZ)) - } - return anchors -} - -/** - * Convenience: candidate anchors from every other floor-placed node. - * Equivalent to `footprintAnchors(collectFloorFootprints(nodes, excludeId))`. - */ -export function collectAlignmentCandidates( - nodes: Readonly>, - excludeId: string, -): AlignmentAnchor[] { - return footprintAnchors(collectFloorFootprints(nodes, excludeId)) -} - /** * Corner anchors for the moving node's footprint relocated so its centre * sits at the proposed (x, z). Corners only — the moving item aligns by its @@ -168,3 +135,48 @@ export function wallSegmentAnchors( { nodeId: id, kind: 'center', x: (start[0] + end[0]) / 2, z: (start[1] + end[1]) / 2 }, ] } + +/** Each vertex of a polygon (slab / ceiling footprint) as a `corner` anchor. */ +export function polygonAnchors( + id: string, + points: readonly (readonly [number, number])[], +): AlignmentAnchor[] { + return points.map(([x, z]) => ({ nodeId: id, kind: 'corner' as const, x, z })) +} + +/** + * Alignment anchors a node contributes to the candidate pool, dispatched by + * kind: floor-placed footprints → corner anchors; walls / fences → segment + * endpoints + midpoint; slabs / ceilings → polygon vertices. Kinds without a + * usable footprint contribute nothing. + */ +export function nodeAlignmentAnchors(node: AnyNode): AlignmentAnchor[] { + if (node.type === 'wall' || node.type === 'fence') { + const seg = node as { id: string; start: [number, number]; end: [number, number] } + return wallSegmentAnchors(seg.id, seg.start, seg.end) + } + if (node.type === 'slab' || node.type === 'ceiling') { + const poly = (node as { polygon?: [number, number][] }).polygon + return poly ? polygonAnchors(node.id, poly) : [] + } + const aabb = footprintAABB(node) + return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : [] +} + +/** + * Anchors from every alignable node except `excludeId` — the unified + * candidate pool every move / placement tool resolves against, so any + * draggable object can align to any other (items, walls, fences, slabs, + * ceilings, columns). + */ +export function collectAlignmentAnchors( + nodes: Readonly>, + excludeId: string, +): AlignmentAnchor[] { + const anchors: AlignmentAnchor[] = [] + for (const node of Object.values(nodes)) { + if (!node || node.id === excludeId) continue + anchors.push(...nodeAlignmentAnchors(node)) + } + return anchors +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 6350043fb..56953c4a2 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -10,14 +10,14 @@ export { resolveAlignment, } from './alignment' export { - collectAlignmentCandidates, - collectFloorFootprints, + collectAlignmentAnchors, type FootprintAABB, footprintAABB, footprintAABBAt, footprintAABBFrom, - footprintAnchors, movingFootprintAnchors, + nodeAlignmentAnchors, + polygonAnchors, wallSegmentAnchors, } from './alignment-anchors' export { diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx new file mode 100644 index 000000000..1ecf71a9d --- /dev/null +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -0,0 +1,271 @@ +'use client' + +import { type AnyNode, type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' +import { sfxEmitter } from '../../lib/sfx-bus' +import useEditor from '../../store/use-editor' +import { + classifyParticipant, + collectParticipants, + computeGroupBox, + CORNER_OFFSET, + expandToComponent, + type Vec2, +} from './group-transform-shared' +import { + ARROW_COLOR, + ARROW_HOVER_COLOR, + ARROW_SCALE, + createMoveCrossHandleGeometry, + swallowNextClick, + useArrowMaterial, +} from './node-arrow-handles' + +/** + * Group-move gizmo — the 4-way cross sibling of `GroupRotateHandle`. When 2+ + * transformable nodes are selected, a single move cross appears at the + * selection's front-left bounding-box corner (the rotate gizmo sits on the + * right). Dragging it slides every selected node by the same ground-plane + * delta; connected (unselected) wall/fence endpoints follow so junctions stay + * welded. Commits the whole slide in one batched `updateNodes` (one undo). + */ +export function GroupMoveHandle() { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const levelId = useViewer((s) => s.selection.levelId) + const mode = useEditor((s) => s.mode) + const movingNode = useEditor((s) => s.movingNode) + const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) + const nodes = useScene((s) => s.nodes) + + const participantIds = useMemo( + () => selectedIds.filter((id) => classifyParticipant(nodes[id as AnyNodeId], levelId) !== null), + [selectedIds, levelId, nodes], + ) + + // Gate on the explicit selection, but move the full connected wall/fence + // component so attached structure slides rigidly as one piece. + const fullIds = useMemo( + () => expandToComponent(participantIds, nodes, levelId), + [participantIds, levelId, nodes], + ) + + const shouldRender = + participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered + + if (!shouldRender) return null + return +} + +function GroupMoveHandleInner({ ids }: { ids: string[] }) { + const { camera, raycaster, gl, scene } = useThree() + const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), []) + const arrowMaterial = useArrowMaterial() + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + // Live ground-plane delta so the gizmo rides along with the group it moves. + const [liveDelta, setLiveDelta] = useState([0, 0]) + const dragCleanupRef = useRef<(() => void) | null>(null) + const frozenCorner = useRef(null) + + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + useEffect(() => () => dragCleanupRef.current?.(), []) + + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE + + // Front-left bbox corner at mid-height (mirrors the rotate gizmo on the + // right), plus the group's base Y for the ground drag plane. + const rest = useMemo(() => { + const box = computeGroupBox(ids) + if (!box) return null + const corner = new Vector3( + box.min.x - CORNER_OFFSET, + (box.min.y + box.max.y) / 2, + box.max.z + CORNER_OFFSET, + ) + return { corner, baseY: box.min.y } + }, [ids]) + + if (!rest) return null + const baseCorner = isDragging && frozenCorner.current ? frozenCorner.current : rest.corner + const corner: [number, number, number] = [ + baseCorner.x + liveDelta[0], + baseCorner.y, + baseCorner.z + liveDelta[1], + ] + + const activate = (event: ThreeEvent) => { + event.stopPropagation() + frozenCorner.current = rest.corner.clone() + const planeY = rest.baseY + + // Snapshot selected participants + connected wall/fence neighbours. + const { starts, links } = collectParticipants( + ids, + useScene.getState().nodes, + useViewer.getState().selection.levelId, + ) + if (starts.length === 0) return + + // Horizontal drag plane at the group's base; delta measured in world XZ + // (= level-local XZ on an axis-aligned level). + const plane = new Plane(new Vector3(0, 1, 0), -planeY) + const ndc = new Vector2() + const setNDC = (clientX: number, clientY: number) => { + const rect = gl.domElement.getBoundingClientRect() + ndc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + } + + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) + const hit = new Vector3() + if (!raycaster.ray.intersectPlane(plane, hit)) return + const startHit = hit.clone() + + document.body.style.cursor = 'grabbing' + sfxEmitter.emit('sfx:item-pick') + useViewer.getState().setInputDragging(true) + useScene.temporal.getState().pause() + setIsDragging(true) + + // Snap the slide to the active grid step so the group lands on the grid + // (Shift bypasses for free movement). Snapping the delta keeps the + // selection's internal layout intact — grid-aligned items stay aligned. + const step = useEditor.getState().gridSnapStep + let lastSnap: Vec2 | null = null + + const onMove = (e: PointerEvent) => { + setNDC(e.clientX, e.clientY) + raycaster.setFromCamera(ndc, camera) + const moveHit = new Vector3() + if (!raycaster.ray.intersectPlane(plane, moveHit)) return + const snap = !e.shiftKey && step > 0 + const dx = snap ? Math.round((moveHit.x - startHit.x) / step) * step : moveHit.x - startHit.x + const dz = snap ? Math.round((moveHit.z - startHit.z) / step) * step : moveHit.z - startHit.z + + // Ticker on each grid-cell crossing, like single-item placement. + if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) { + sfxEmitter.emit('sfx:grid-snap') + lastSnap = [dx, dz] + } + + const overrides = useLiveNodeOverrides.getState() + for (const s of starts) { + if (s.kind === 'endpoint') { + overrides.set(s.id, { + start: [s.start[0] + dx, s.start[1] + dz], + end: [s.end[0] + dx, s.end[1] + dz], + }) + } else { + // Slide on the floor: XZ shift, Y and rotation untouched. + overrides.set(s.id, { + position: [s.position[0] + dx, s.position[1], s.position[2] + dz], + }) + } + useScene.getState().markDirty(s.id) + } + + // Shared endpoints of connected neighbours follow by the same delta so + // the junction stays welded; the far end stays put. + for (const l of links) { + overrides.set(l.id, { + start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start, + end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end, + }) + useScene.getState().markDirty(l.id) + } + + setLiveDelta([dx, dz]) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' + useScene.temporal.getState().resume() + useViewer.getState().setInputDragging(false) + setIsDragging(false) + setLiveDelta([0, 0]) + frozenCorner.current = null + dragCleanupRef.current = null + } + + const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] + + const commitFromOverrides = () => { + const overrides = useLiveNodeOverrides.getState() + const updates: { id: AnyNodeId; data: Partial }[] = [] + for (const id of affectedIds) { + const patch = overrides.get(id) + if (patch) updates.push({ id, data: patch as Partial }) + } + return updates + } + + const onUp = () => { + // Eat the click that follows pointer-up so the selection manager doesn't + // treat it as a canvas click and clear the multi-selection. + swallowNextClick() + sfxEmitter.emit('sfx:item-place') + const updates = commitFromOverrides() + // Resume before the commit so the single batched `updateNodes` is the one + // tracked set — collapsing the whole group move into one undo. + useScene.temporal.getState().resume() + if (updates.length > 0) useScene.getState().updateNodes(updates) + for (const id of affectedIds) { + useLiveNodeOverrides.getState().clear(id) + useScene.getState().markDirty(id) + } + cleanup() + } + + const onCancel = () => { + for (const id of affectedIds) { + useLiveNodeOverrides.getState().clear(id) + useScene.getState().markDirty(id) + } + cleanup() + } + + dragCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } + + return createPortal( + + { + event.stopPropagation() + setIsHovered(true) + if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'move' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'move') document.body.style.cursor = '' + }} + renderOrder={1010} + /> + , + scene, + ) +} + +export default GroupMoveHandle diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index bdb1f9dc1..8a0736b59 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -1,18 +1,21 @@ 'use client' -import { - type AnyNode, - type AnyNodeId, - sceneRegistry, - useLiveNodeOverrides, - useScene, -} from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { Box3, OrthographicCamera, Plane, Vector2, Vector3 } from 'three' +import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { + CORNER_OFFSET, + classifyParticipant, + collectParticipants, + computeGroupBox, + expandToComponent, + type Vec2, + type Vec3, +} from './group-transform-shared' import { ARROW_COLOR, ARROW_HOVER_COLOR, @@ -21,25 +24,12 @@ import { GuideRing, RotationGuide, type RotationGuideData, + swallowNextClick, useArrowMaterial, } from './node-arrow-handles' const ROTATE_SNAP = Math.PI / 12 // 15° -type MovableNode = AnyNode & { - position: [number, number, number] - rotation: [number, number, number] -} - -function isMovable(node: AnyNode | undefined, levelId: string | null): node is MovableNode { - if (!node || node.parentId !== levelId) return false - const p = (node as { position?: unknown }).position - const r = (node as { rotation?: unknown }).rotation - const isVec3 = (v: unknown): v is [number, number, number] => - Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number') - return isVec3(p) && isVec3(r) -} - /** * Group-rotate gizmo. When 2+ "movable" nodes (position + rotation, sitting * directly on the active level) are selected, a single rotation handle appears @@ -61,16 +51,24 @@ export function GroupRotateHandle() { const nodes = useScene((s) => s.nodes) const participantIds = useMemo( - () => selectedIds.filter((id) => isMovable(nodes[id as AnyNodeId], levelId)), + () => selectedIds.filter((id) => classifyParticipant(nodes[id as AnyNodeId], levelId) !== null), [selectedIds, levelId, nodes], ) + // Gate on the explicit selection (so a single connected wall still gets the + // per-node handles), but transform the full connected wall/fence component so + // attached structure rotates rigidly as one piece. + const fullIds = useMemo( + () => expandToComponent(participantIds, nodes, levelId), + [participantIds, levelId, nodes], + ) + const shouldRender = participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered if (!shouldRender) return null - // Remount when the participant set changes so the rest pivot re-seeds cleanly. - return + // Remount when the moving set changes so the rest pivot re-seeds cleanly. + return } function GroupRotateHandleInner({ ids }: { ids: string[] }) { @@ -81,7 +79,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { const [isDragging, setIsDragging] = useState(false) const [guide, setGuide] = useState(null) const dragCleanupRef = useRef<(() => void) | null>(null) - const frozenPivot = useRef(null) + const frozenRest = useRef<{ pivot: Vector3; corner: Vector3 } | null>(null) useEffect(() => { arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) @@ -93,59 +91,64 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE * 1.05 - // World-space bounding-box center of the selected meshes (XZ), Y at the - // group's base. Levels are axis-aligned in XZ, so world XZ coincides with - // each node's level-local `position` XZ — letting us rotate `position` - // directly against this pivot without per-node frame conversion. - const restPivot = useMemo(() => { - const box = new Box3() - const tmp = new Box3() - let found = false - for (const id of ids) { - const obj = sceneRegistry.nodes.get(id) - if (!obj) continue - obj.updateWorldMatrix(true, true) - tmp.setFromObject(obj) - if (tmp.isEmpty()) continue - box.union(tmp) - found = true - } - if (!found) return null - return new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2) + // World-space bounding box of the selected meshes. Levels are axis-aligned in + // XZ, so world XZ coincides with each node's level-local placement — letting + // us rotate `position` / `start` / `end` directly against the pivot without + // per-node frame conversion. + // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin + // - `corner` = front-right bbox corner at mid-height → where the gizmo sits + const rest = useMemo(() => { + const box = computeGroupBox(ids) + if (!box) return null + const pivot = new Vector3( + (box.min.x + box.max.x) / 2, + box.min.y, + (box.min.z + box.max.z) / 2, + ) + const corner = new Vector3( + box.max.x + CORNER_OFFSET, + (box.min.y + box.max.y) / 2, + box.max.z + CORNER_OFFSET, + ) + return { pivot, corner } }, [ids]) - if (!restPivot) return null - const pivot = isDragging && frozenPivot.current ? frozenPivot.current : restPivot + if (!rest) return null + const active = isDragging && frozenRest.current ? frozenRest.current : rest + const corner = active.corner const activate = (event: ThreeEvent) => { event.stopPropagation() - const center = restPivot.clone() - frozenPivot.current = center - - // Snapshot each participant's pre-drag transform from the store. - const sceneNodes = useScene.getState().nodes - const starts = ids - .map((id) => { - const node = sceneNodes[id as AnyNodeId] as MovableNode | undefined - if (!node) return null - return { - id: id as AnyNodeId, - position: [...node.position] as [number, number, number], - rotation: [...node.rotation] as [number, number, number], - } - }) - .filter((s): s is NonNullable => s !== null) + frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() } + const center = rest.pivot.clone() + + // Snapshot the selected participants + connected wall/fence neighbours whose + // shared endpoints must follow the rotation (so junctions stay welded). + const { starts, links } = collectParticipants( + ids, + useScene.getState().nodes, + useViewer.getState().selection.levelId, + ) if (starts.length === 0) return // Horizontal drag plane at the pivot; bearing measured around the pivot. const plane = new Plane(new Vector3(0, 1, 0), -center.y) const angleOf = (p: Vector3) => Math.atan2(p.z - center.z, p.x - center.x) - // Wedge radius tracks how far the group spreads from the pivot. + // Wedge radius tracks how far the group spreads from the pivot — sample each + // participant's anchor point(s). let spread = 0 + const reach = (x: number, z: number) => { + spread = Math.max(spread, Math.hypot(x - center.x, z - center.z)) + } for (const s of starts) { - spread = Math.max(spread, Math.hypot(s.position[0] - center.x, s.position[2] - center.z)) + if (s.kind === 'endpoint') { + reach(s.start[0], s.start[1]) + reach(s.end[0], s.end[1]) + } else { + reach(s.position[0], s.position[2]) + } } const guideRadius = Math.min(Math.max(spread * 0.6, 0.3), 3) @@ -180,29 +183,46 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { while (delta < -Math.PI) delta += 2 * Math.PI if (e.shiftKey) delta = Math.round(delta / ROTATE_SNAP) * ROTATE_SNAP - // Orbit each node's position CCW by `delta` (atan2 x→z sense) and turn - // its yaw by `-delta` to match three.js Y-rotation handedness (same + // Orbit each node's anchor point(s) CCW by `delta` (atan2 x→z sense) and + // turn its yaw by `-delta` to match three.js Y-rotation handedness (same // convention as the single-item rotate handle in item/definition.ts). + // Endpoint nodes (walls/fences) have no yaw — swinging both endpoints + // around the pivot rotates them rigidly; their curveOffset sagitta is + // rotation-invariant, so arcs are preserved. const cos = Math.cos(delta) const sin = Math.sin(delta) + const rot = (x: number, z: number): Vec2 => { + const dx = x - center.x + const dz = z - center.z + return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos] + } const overrides = useLiveNodeOverrides.getState() for (const s of starts) { - const dx = s.position[0] - center.x - const dz = s.position[2] - center.z - const position: [number, number, number] = [ - center.x + dx * cos - dz * sin, - s.position[1], - center.z + dx * sin + dz * cos, - ] - const rotation: [number, number, number] = [ - s.rotation[0], - s.rotation[1] - delta, - s.rotation[2], - ] - overrides.set(s.id, { position, rotation }) + if (s.kind === 'endpoint') { + overrides.set(s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }) + } else { + const [px, pz] = rot(s.position[0], s.position[2]) + const position: Vec3 = [px, s.position[1], pz] + const rotation = + s.kind === 'vec3' + ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3) + : s.rotation - delta + overrides.set(s.id, { position, rotation }) + } useScene.getState().markDirty(s.id) } + // Drag each linked neighbour's shared endpoint to the same rotated spot + // (rot is deterministic, so it lands exactly on the selected wall's + // rotated endpoint), keeping the junction welded; the far end stays put. + for (const l of links) { + overrides.set(l.id, { + start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start, + end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end, + }) + useScene.getState().markDirty(l.id) + } + if (Math.abs(delta) < 0.0087) { setGuide(null) } else { @@ -232,39 +252,44 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { useViewer.getState().setInputDragging(false) setIsDragging(false) setGuide(null) - frozenPivot.current = null + frozenRest.current = null dragCleanupRef.current = null } + const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] + const commitFromOverrides = () => { const overrides = useLiveNodeOverrides.getState() const updates: { id: AnyNodeId; data: Partial }[] = [] - for (const s of starts) { - const patch = overrides.get(s.id) - if (patch) updates.push({ id: s.id, data: patch as Partial }) + for (const id of affectedIds) { + const patch = overrides.get(id) + if (patch) updates.push({ id, data: patch as Partial }) } return updates } const onUp = () => { + // Eat the click that follows pointer-up so the selection manager doesn't + // treat it as a canvas click and clear the multi-selection. + swallowNextClick() sfxEmitter.emit('sfx:item-place') const updates = commitFromOverrides() // Resume before the commit so the single batched `updateNodes` is the // one tracked set — collapsing the whole group rotation into one undo. useScene.temporal.getState().resume() if (updates.length > 0) useScene.getState().updateNodes(updates) - for (const s of starts) { - useLiveNodeOverrides.getState().clear(s.id) - useScene.getState().markDirty(s.id) + for (const id of affectedIds) { + useLiveNodeOverrides.getState().clear(id) + useScene.getState().markDirty(id) } cleanup() } const onCancel = () => { // Revert: drop overrides + mark dirty so renderers rebuild from the store. - for (const s of starts) { - useLiveNodeOverrides.getState().clear(s.id) - useScene.getState().markDirty(s.id) + for (const id of affectedIds) { + useLiveNodeOverrides.getState().clear(id) + useScene.getState().markDirty(id) } cleanup() } @@ -278,11 +303,11 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { return createPortal( <> {(isHovered || isDragging) && ( - + )} - + + Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number') +const isVec2 = (v: unknown): v is Vec2 => + Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number') + +// How a participant's placement transforms rigidly around / with the group: +// - 'vec3' position + [x,y,z] rotation (items, …) +// - 'scalar' position + numeric rotation (columns) +// - 'endpoint' start/end tuples (walls, fences) +export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint' + +// A selected node qualifies when it sits directly on the active level and its +// placement is one of the transformable shapes. Doors/windows parent to their +// wall (not the level), so they're excluded here and ride their wall. +export function classifyParticipant( + node: AnyNode | undefined, + levelId: string | null, +): ParticipantKind | null { + if (!node || node.parentId !== levelId) return null + const p = (node as { position?: unknown }).position + const r = (node as { rotation?: unknown }).rotation + const start = (node as { start?: unknown }).start + const end = (node as { end?: unknown }).end + if (isVec3(p) && isVec3(r)) return 'vec3' + if (isVec3(p) && typeof r === 'number') return 'scalar' + if (isVec2(start) && isVec2(end)) return 'endpoint' + return null +} + +// Pre-drag placement snapshot + how to transform it. +export type ParticipantStart = + | { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 } + | { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number } + | { id: AnyNodeId; kind: 'endpoint'; start: Vec2; end: Vec2 } + +// An unselected wall/fence sharing a junction with a transforming endpoint. Only +// the touching endpoint(s) follow, so the neighbour stays attached while its far +// end stays put (it stretches, mirroring single-wall move). +export type LinkedNeighbor = { + id: AnyNodeId + start: Vec2 + end: Vec2 + startLinked: boolean + endLinked: boolean +} + +const nearPoint = (a: Vec2, b: Vec2) => + Math.abs(a[0] - b[0]) <= JUNCTION_EPS && Math.abs(a[1] - b[1]) <= JUNCTION_EPS + +// Snapshot the selected participants and the connected (unselected) wall/fence +// neighbours whose shared endpoints should follow the transform. +export function collectParticipants( + ids: string[], + sceneNodes: Record, + levelId: string | null, +): { starts: ParticipantStart[]; links: LinkedNeighbor[] } { + const starts: ParticipantStart[] = [] + for (const id of ids) { + const node = sceneNodes[id] + const kind = classifyParticipant(node, levelId) + if (!node || !kind) continue + if (kind === 'vec3') { + const n = node as AnyNode & { position: Vec3; rotation: Vec3 } + starts.push({ + id: id as AnyNodeId, + kind, + position: [n.position[0], n.position[1], n.position[2]], + rotation: [n.rotation[0], n.rotation[1], n.rotation[2]], + }) + } else if (kind === 'scalar') { + const n = node as AnyNode & { position: Vec3; rotation: number } + starts.push({ + id: id as AnyNodeId, + kind, + position: [n.position[0], n.position[1], n.position[2]], + rotation: n.rotation, + }) + } else { + const n = node as AnyNode & { start: Vec2; end: Vec2 } + starts.push({ + id: id as AnyNodeId, + kind, + start: [n.start[0], n.start[1]], + end: [n.end[0], n.end[1]], + }) + } + } + + const endpoints: Vec2[] = [] + for (const s of starts) { + if (s.kind === 'endpoint') endpoints.push(s.start, s.end) + } + const links: LinkedNeighbor[] = [] + if (endpoints.length > 0) { + const selected = new Set(starts.map((s) => s.id)) + for (const [nid, node] of Object.entries(sceneNodes)) { + if (selected.has(nid as AnyNodeId)) continue + if (classifyParticipant(node, levelId) !== 'endpoint') continue + const n = node as AnyNode & { start: Vec2; end: Vec2 } + const start: Vec2 = [n.start[0], n.start[1]] + const end: Vec2 = [n.end[0], n.end[1]] + const startLinked = endpoints.some((p) => nearPoint(start, p)) + const endLinked = endpoints.some((p) => nearPoint(end, p)) + if (startLinked || endLinked) { + links.push({ id: nid as AnyNodeId, start, end, startLinked, endLinked }) + } + } + } + return { starts, links } +} + +// Grow a selection to the full connected component of walls/fences: any +// endpoint node transitively reachable through shared junctions from a selected +// endpoint node joins in, so the whole rigid structure transforms as one piece +// (rather than tearing/stretching at the boundary). Non-endpoint selections +// (items, columns) pass through unchanged. +export function expandToComponent( + selectedIds: string[], + sceneNodes: Record, + levelId: string | null, +): string[] { + const endpoints: { id: string; start: Vec2; end: Vec2 }[] = [] + for (const [id, node] of Object.entries(sceneNodes)) { + if (classifyParticipant(node, levelId) === 'endpoint') { + const n = node as AnyNode & { start: Vec2; end: Vec2 } + endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] }) + } + } + const included = new Set(selectedIds) + if (!endpoints.some((e) => included.has(e.id))) return selectedIds + + let changed = true + while (changed) { + changed = false + for (const e of endpoints) { + if (included.has(e.id)) continue + const touches = endpoints.some( + (o) => + included.has(o.id) && + (nearPoint(e.start, o.start) || + nearPoint(e.start, o.end) || + nearPoint(e.end, o.start) || + nearPoint(e.end, o.end)), + ) + if (touches) { + included.add(e.id) + changed = true + } + } + } + return Array.from(included) +} + +// World-space union bounding box of the selected meshes, or null if none are +// mounted yet. Levels are axis-aligned in XZ, so world XZ coincides with each +// node's level-local placement — letting callers transform placement directly +// against box-derived points without per-node frame conversion. +export function computeGroupBox(ids: string[]): Box3 | null { + const box = new Box3() + const tmp = new Box3() + let found = false + for (const id of ids) { + const obj = sceneRegistry.nodes.get(id) + if (!obj) continue + obj.updateWorldMatrix(true, true) + tmp.setFromObject(obj) + if (tmp.isEmpty()) continue + box.union(tmp) + found = true + } + return found ? box : null +} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index fbe40b8d2..cd8243f4b 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -60,6 +60,7 @@ import { FloatingActionMenu } from './floating-action-menu' import { FloatingBuildingActionMenu } from './floating-building-action-menu' import { FloorplanPanel } from './floorplan-panel' import { Grid } from './grid' +import { GroupMoveHandle } from './group-move-handle' import { GroupRotateHandle } from './group-rotate-handle' import { NodeArrowHandles } from './node-arrow-handles' import { SelectionManager } from './selection-manager' @@ -607,6 +608,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!noEditing && } {!noEditing && } {!noEditing && } + {!noEditing && } {!noEditing && } {!noEditing && } {!noEditing && } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index e19dbe304..7f58b3dc2 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -110,9 +110,9 @@ export const ARROW_HOVER_COLOR = '#a5b4fc' // unchanged. export function createRotateArrowHandleGeometry() { const R = 0.2 - const ribbonHalfWidth = 0.02 // ribbon thickness / 2 + const ribbonHalfWidth = 0.028 // ribbon thickness / 2 const halfSweep = Math.PI / 3 // 60° per side → 120° total arc - const headHalfWidth = 0.045 // arrowhead wings extend this far past ribbon + const headHalfWidth = 0.05 // arrowhead wings extend this far past ribbon const headOvershoot = 0.075 // tangential reach of the arrowhead tip const rIn = R - ribbonHalfWidth const rOut = R + ribbonHalfWidth @@ -170,16 +170,16 @@ export function createRotateArrowHandleGeometry() { shape.closePath() const geometry = new ExtrudeGeometry(shape, { - depth: 0.06, + depth: 0.045, bevelEnabled: true, bevelThickness: 0.018, - bevelSize: 0.012, + bevelSize: 0.02, bevelOffset: 0, - bevelSegments: 6, + bevelSegments: 8, curveSegments: 24, steps: 1, }) - geometry.translate(0, 0, -0.03) + geometry.translate(0, 0, -0.0225) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() geometry.computeBoundingSphere() @@ -221,8 +221,8 @@ function createArrowHandleGeometry() { // merge into the 4-way move cross. function createDoubleArrowShape(): Shape { const L = 0.36 // half-length to each tip - const rw = 0.03 // ribbon half-width - const hw = 0.12 // arrowhead half-width + const rw = 0.042 // ribbon half-width + const hw = 0.13 // arrowhead half-width // Long inner ribbon so opposing arrowheads sit well apart rather than // meeting in a cramped knot at the centre. const hx = 0.2 // where each arrowhead meets the ribbon @@ -244,20 +244,20 @@ function createDoubleArrowShape(): Shape { // 4-way move cross: two double-headed arrows (±X and ±Z) lying flat in the // XZ plane. Drawn on top (depthTest off, shared arrow material) so it reads // as a floor-move grip centred on the item. -function createMoveCrossHandleGeometry() { +export function createMoveCrossHandleGeometry() { const shape = createDoubleArrowShape() const extrudeOpts = { - depth: 0.06, + depth: 0.045, bevelEnabled: true, bevelThickness: 0.018, - bevelSize: 0.012, + bevelSize: 0.02, bevelOffset: 0, - bevelSegments: 6, + bevelSegments: 8, curveSegments: 8, steps: 1, } const armX = new ExtrudeGeometry(shape, extrudeOpts) - armX.translate(0, 0, -0.03) + armX.translate(0, 0, -0.0225) armX.rotateX(-Math.PI / 2) // lay flat → points along ±X in XZ const armZ = armX.clone() armZ.rotateY(Math.PI / 2) // second arm → points along ±Z @@ -275,7 +275,7 @@ function createMoveCrossHandleGeometry() { return merged } -function swallowNextClick() { +export function swallowNextClick() { const swallow = (clickEvent: Event) => { clickEvent.stopPropagation() clickEvent.preventDefault() @@ -1767,7 +1767,8 @@ function TapActionArrow({ const position = descriptor.placement.position(node, placementSceneApi) const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 const shape = descriptor.shape ?? 'arrow' - const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize') + const cursor: Cursor = + descriptor.cursor ?? (shape === 'corner-picker' || shape === 'move-cross' ? 'move' : 'ew-resize') const onActivate = (event: ThreeEvent) => { event.stopPropagation() @@ -1803,6 +1804,20 @@ function TapActionArrow({ ) } + if (shape === 'move-cross') { + return ( + + ) + } + // Default 'arrow' shape — the standard chevron. return ( ) => void + onEnter: (event: ThreeEvent) => void + onLeave: (event: ThreeEvent) => void +}) { + const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), []) + const arrowMaterial = useArrowMaterial() + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + + const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE + const iconRotation: [number, number, number] = tilt ? [Math.PI / 2, 0, 0] : [0, 0, 0] + return ( + + + + ) +} + function ArrowShape({ position, rotationY, 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 6bb55be03..b019070cc 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -4,7 +4,7 @@ import { type AnyNode, type AnyNodeId, type CeilingEvent, - collectAlignmentCandidates, + collectAlignmentAnchors, emitter, type GridEvent, getScaledDimensions, @@ -606,11 +606,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea revalidate() + // ---- Press-drag commit-on-release ---- + // When the move was engaged by the press-drag move cross (vs. click-to- + // place), commit on pointer-up instead of waiting for a click. Each surface + // move handler records how to commit at the current cursor; the release + // replays it. Captured once at setup — a fresh coordinator mounts per move. + const dragMode = useEditor.getState().placementDragMode + let releaseCommit: (() => void) | null = null + // Eat the click the browser fires after pointer-up so the surface + // `:click` handlers don't commit a second time. + const swallowNextClick = () => { + const swallow = (e: Event) => { + e.stopPropagation() + e.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300) + } + const onReleaseCommit = () => { + if (!releaseCommit) return + const commit = releaseCommit + releaseCommit = null + swallowNextClick() + commit() + } + // ---- Floor Handlers ---- let previousGridPos: [number, number, number] | null = null const onGridMove = (event: GridEvent) => { + releaseCommit = () => onGridClick(event) // Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now if (draftNode.current === null && asset.attachTo === undefined) { configRef.current.initDraft(gridPosition.current) @@ -632,7 +658,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea let alignZ = 0 const bypassAlign = event.nativeEvent?.altKey === true if (!bypassAlign && draft) { - alignmentCandidates ??= collectAlignmentCandidates(useScene.getState().nodes, draft.id) + alignmentCandidates ??= collectAlignmentAnchors(useScene.getState().nodes, draft.id) const ar = resolveAlignment({ moving: movingFootprintAnchors( draft as unknown as AnyNode, @@ -751,6 +777,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallMove = (event: WallEvent) => { + releaseCommit = () => onWallClick(event) has3DPointerDrivenMoveRef.current = true const ctx = getContext() @@ -965,6 +992,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onItemMove = (event: ItemEvent) => { if (event.node.id === draftNode.current?.id) return + releaseCommit = () => onItemClick(event) has3DPointerDrivenMoveRef.current = true const ctx = getContext() @@ -1189,6 +1217,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onCeilingMove = (event: CeilingEvent) => { + releaseCommit = () => onCeilingClick(event) has3DPointerDrivenMoveRef.current = true if (!draftNode.current && placementState.current.surface === 'ceiling') { const nodes = useScene.getState().nodes @@ -1317,6 +1346,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onShelfMove = (event: ShelfEvent) => { + releaseCommit = () => onShelfClick(event) has3DPointerDrivenMoveRef.current = true const ctx = getContext() if (ctx.state.surface !== 'shelf-surface') { @@ -1569,9 +1599,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) + if (dragMode) window.addEventListener('pointerup', onReleaseCommit) return () => { tearingDown = true + if (dragMode) window.removeEventListener('pointerup', onReleaseCommit) unsubDraftWatch() useAlignmentGuides.getState().clear() // Clear live transform for any remaining draft diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index bc773fe4a..ddfd3e02b 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -5,7 +5,7 @@ import '../../../three-types' import { type AnyNode, type AnyNodeId, - collectAlignmentCandidates, + collectAlignmentAnchors, type EventSuffix, emitter, type GridEvent, @@ -174,12 +174,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { }) } - // Static alignment candidates — the corner anchors of every OTHER - // floor-placed node, gathered once at drag start (the scene graph is - // stable during an imperative move). Coords are building-local, the same - // frame as `event.localPosition` and the rendered cursor, so the guide - // dots line up with the cursor. - const alignmentCandidates = collectAlignmentCandidates(useScene.getState().nodes, node.id) + // Static alignment candidates — anchors of every OTHER alignable object + // (items, walls, fences, slabs, ceilings, columns), gathered once at drag + // start (the scene graph is stable during an imperative move). Coords are + // building-local, the same frame as `event.localPosition` and the + // rendered cursor, so the guide dots line up with the cursor. + const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, node.id) const onGridMove = (event: GridEvent) => { let x = snapToGridStep(event.localPosition[0]) diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 1aa09ee71..20f7e5047 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -487,6 +487,10 @@ const BoxSelectToolInner: React.FC = () => { const onCanvasPointerDown = (e: PointerEvent) => { if (e.button !== 0) return if (useViewer.getState().cameraDragging) return + // A gizmo/handle drag is underway (group rotate/move, resize arrows, …). + // Those use raw window listeners too, so without this guard box-select + // would run in parallel and clobber the selection on release. + if (useViewer.getState().inputDragging) return const point = raycastToGround(e) if (!point) return @@ -504,6 +508,18 @@ const BoxSelectToolInner: React.FC = () => { const onCanvasPointerUp = (e: PointerEvent) => { if (e.button !== 0) return + // If a gizmo/handle drag is in progress, don't let box-select replace the + // selection. Canvas listeners fire before the gizmo's window pointer-up + // (which clears `inputDragging`), so this still reads true here. Reset our + // own state and bail. + if (useViewer.getState().inputDragging) { + pointerDown.current = false + isDragging.current = false + if (rectFillRef.current) rectFillRef.current.visible = false + if (outlineRef.current) outlineRef.current.visible = false + syncPreviewSelectedIds([]) + return + } if (!pointerDown.current) return if (isDragging.current) { @@ -585,6 +601,8 @@ const BoxSelectToolInner: React.FC = () => { } if (!pointerDown.current) return + // A gizmo/handle drag took over — don't draw a selection box underneath it. + if (useViewer.getState().inputDragging) return currentPoint.current.set(snappedX, event.position[1], snappedZ) diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index 1e461661e..d6fec9c88 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -995,6 +995,7 @@ export function SecondaryToggles() { return (
+
) } diff --git a/packages/editor/src/lib/editor-api.ts b/packages/editor/src/lib/editor-api.ts index 3a2229078..4333003dc 100644 --- a/packages/editor/src/lib/editor-api.ts +++ b/packages/editor/src/lib/editor-api.ts @@ -33,6 +33,7 @@ export function createEditorApi(): EditorApi { return { engageMove(node: AnyNode) { const editor = useEditor.getState() + editor.setPlacementDragMode(false) // `setMovingNode` is typed against a narrower union than `AnyNode` // (every concrete kind enumerated). Descriptors pass any node; the // cast lets registry-driven move kinds through without forcing a @@ -43,6 +44,17 @@ export function createEditorApi(): EditorApi { editor.setCurvingWall(null) editor.setCurvingFence(null) }, + engageMoveDrag(node: AnyNode) { + const editor = useEditor.getState() + // Flag drag mode BEFORE mounting the move tool so the coordinator reads + // it at setup and wires its commit-on-release listener. + editor.setPlacementDragMode(true) + editor.setMovingNode(node as Parameters[0]) + editor.setMovingWallEndpoint(null) + editor.setMovingFenceEndpoint(null) + editor.setCurvingWall(null) + editor.setCurvingFence(null) + }, engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') { endpointEngagers[node.type]?.(node, endpoint, useEditor.getState()) }, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 69dfebfbc..2312c69ab 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -204,6 +204,13 @@ type EditorState = { | StairSegmentNode | BuildingNode | null + /** + * True while a move was engaged by a press-drag gizmo (the on-canvas move + * cross) rather than a click-to-place flow. The placement coordinator reads + * this to commit on pointer-release instead of waiting for a click. + */ + placementDragMode: boolean + setPlacementDragMode: (dragMode: boolean) => void setMovingNode: ( node: | ItemNode @@ -688,6 +695,8 @@ const useEditor = create()( | StairSegmentNode | BuildingNode | null, + placementDragMode: false, + setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }), setMovingNode: (node) => set( node === null @@ -695,7 +704,8 @@ const useEditor = create()( // non-owning side's effect cleanup — which fires after // `setMovingNode(null)` propagates — can still read who // finalised. The next non-null `setMovingNode` resets it. - { movingNode: null } + // Always clear the press-drag flag when a move ends. + { movingNode: null, placementDragMode: false } : { movingNode: node, movingNodeOrigin: null }, ), movingNodeOrigin: null as '2d' | '3d' | null, diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 5dc303462..4a10c87e4 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -3,9 +3,13 @@ import { type AnyNodeId, type CeilingNode, + collectAlignmentAnchors, emitter, type GridEvent, + polygonAnchors, + resolveAlignment, sceneRegistry, + useAlignmentGuides, useLiveTransforms, useScene, } from '@pascal-app/core' @@ -32,6 +36,9 @@ function snap(value: number) { return Math.round(value * 2) / 2 } +/** Figma-style alignment-snap threshold (meters), matching the other tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + function translatePolygon( polygon: Array<[number, number]>, deltaX: number, @@ -104,6 +111,10 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const height = heightRef.current const ceilingId = node.id + // Alignment candidates — every other alignable object's anchors, + // gathered once (the scene graph is stable during the drag). + const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, ceilingId) + let wasCommitted = false const applyPreview = (deltaX: number, deltaZ: number) => { @@ -146,7 +157,29 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const anchor = dragAnchorRef.current ?? [localX, localZ] dragAnchorRef.current = anchor - applyPreview(localX - anchor[0], localZ - anchor[1]) + let deltaX = localX - anchor[0] + let deltaZ = localZ - anchor[1] + + // Figma-style alignment snap: align the ceiling's translated polygon + // vertices to other objects' anchors; fold the snap into the delta and + // publish a guide. Alt bypasses. + const bypass = event.nativeEvent?.altKey === true + if (!bypass && alignmentCandidates.length > 0) { + const result = resolveAlignment({ + moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)), + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + deltaX += result.snap.dx + deltaZ += result.snap.dz + } + useAlignmentGuides.getState().set(result.guides) + } else { + useAlignmentGuides.getState().clear() + } + + applyPreview(deltaX, deltaZ) } const onGridClick = (event: GridEvent) => { @@ -167,6 +200,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { useScene.getState().markDirty(ceilingId as AnyNodeId) } useLiveTransforms.getState().clear(ceilingId) + useAlignmentGuides.getState().clear() triggerSFX('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [ceilingId] }) @@ -176,6 +210,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { const onCancel = () => { clearPreview() + useAlignmentGuides.getState().clear() useViewer.getState().setSelection({ selectedIds: [ceilingId] }) markToolCancelConsumed() exitMoveMode() @@ -186,6 +221,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { emitter.on('tool:cancel', onCancel) return () => { + useAlignmentGuides.getState().clear() if (!wasCommitted) { clearPreview() } else { diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index d16c446ce..25bf7165c 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -4,9 +4,13 @@ import { type AnyNodeId, type ColumnNode, ColumnNode as ColumnNodeSchema, + collectAlignmentAnchors, emitter, type GridEvent, + movingFootprintAnchors, + resolveAlignment, sceneRegistry, + useAlignmentGuides, useLiveTransforms, useScene, } from '@pascal-app/core' @@ -37,6 +41,9 @@ const snapToGridStep = (value: number) => { /** 90° steps, matching the GLB item / shelf placement rotation. */ const ROTATION_STEP = Math.PI / 2 +/** Figma-style alignment-snap threshold (meters), matching the other tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + function MoveColumnTool({ node }: { node: ColumnNode }) { const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) @@ -61,6 +68,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { : {} const isNew = !!meta.isNew + // Alignment candidates — every other alignable object's anchors, gathered + // once (the scene graph is stable during the imperative drag). + const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, node.id) + const applyPreview = (position: [number, number, number]) => { lastPosition = position setPreviewPosition(position) @@ -77,11 +88,29 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const onGridMove = (event: GridEvent) => { hasMoved = true - applyPreview([ - snapToGridStep(event.localPosition[0]), - 0, - snapToGridStep(event.localPosition[2]), - ]) + let x = snapToGridStep(event.localPosition[0]) + let z = snapToGridStep(event.localPosition[2]) + + // Figma-style alignment snap on top of grid snap; Alt bypasses. The + // guide connects to the candidate's nearest real anchor (resolver + // tie-break), so the dot always sits on an actual point. + const bypass = event.nativeEvent?.altKey === true + if (!bypass && alignmentCandidates.length > 0) { + const result = resolveAlignment({ + moving: movingFootprintAnchors(node, x, z, rotationY), + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + x += result.snap.dx + z += result.snap.dz + } + useAlignmentGuides.getState().set(result.guides) + } else { + useAlignmentGuides.getState().clear() + } + + applyPreview([x, 0, z]) } // R / T rotate the dragged column about Y in 90° steps (matches the move @@ -99,11 +128,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const onGridClick = (event: GridEvent) => { if (!hasMoved) return - const position: [number, number, number] = [ - snapToGridStep(event.localPosition[0]), - 0, - snapToGridStep(event.localPosition[2]), - ] + useAlignmentGuides.getState().clear() + // Commit at the last previewed position so the alignment snap (which + // may pull off-grid) is preserved, rather than re-snapping the raw + // click to the grid. + const position: [number, number, number] = [...lastPosition] const nodeId = (node as { id?: ColumnNode['id'] }).id if (nodeId && useScene.getState().nodes[nodeId]) { @@ -134,6 +163,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const onCancel = () => { useLiveTransforms.getState().clear(node.id) + useAlignmentGuides.getState().clear() const m = sceneRegistry.nodes.get(node.id) if (m) { m.position.set(node.position[0], node.position[1], node.position[2]) @@ -155,6 +185,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) useLiveTransforms.getState().clear(node.id) + useAlignmentGuides.getState().clear() if (!committed) { const m = sceneRegistry.nodes.get(node.id) if (m) { diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 9791a7661..d1978c9ac 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -5,6 +5,7 @@ import type { NodeDefinition, WallNode, } from '@pascal-app/core' +import { scaleHandleHeight } from './door-math' import { buildDoorFloorplan } from './floorplan' import { doorWidthAffordance } from './floorplan-affordances' import { doorFloorplanMoveTarget } from './floorplan-move' @@ -85,9 +86,12 @@ function doorHeightHandle(): HandleDescriptor { currentValue: (n) => n.height, apply: (initial, newHeight) => { const bottom = initial.position[1] - initial.height / 2 + // Scale the handle so it tracks the door instead of staying glued to a + // fixed floor height (shared with the panel's Height slider). return { height: newHeight, position: [initial.position[0], bottom + newHeight / 2, initial.position[2]], + handleHeight: scaleHandleHeight(initial.handleHeight, initial.height, newHeight), } }, placement: { diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index 9a2dc9df6..15a1b2ddb 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -8,6 +8,22 @@ import { type WindowNode, } from '@pascal-app/core' +/** + * Keep the door handle at the same relative height when the door is resized: + * scale it by the height ratio, then clamp to the panel's slider bounds + * [0.5, height - 0.1] so it never lands outside the (possibly shrunk) door. + * Used by both the height-resize arrow and the panel's Height slider so the + * handle tracks the door whichever way it's resized. + */ +export function scaleHandleHeight( + handleHeight: number, + oldHeight: number, + newHeight: number, +): number { + const ratio = oldHeight > 0 ? newHeight / oldHeight : 1 + return Math.min(Math.max(handleHeight * ratio, 0.5), Math.max(0.5, newHeight - 0.1)) +} + /** * Converts wall-local (X along wall, Y = height above wall base) to world XYZ. */ diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index 4bba801f2..e778743b0 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -16,6 +16,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' +import { scaleHandleHeight } from './door-math' const doorTypeOptions = [ { label: 'Hinged', value: 'hinged', available: true }, @@ -716,7 +717,13 @@ export default function DoorPanel() { max={4} min={1.0} onChange={(v) => - handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] }) + handleUpdate({ + height: v, + position: [node.position[0], v / 2, node.position[2]], + // Keep the handle at the same relative height as the door resizes, + // matching the height-resize arrow. + handleHeight: scaleHandleHeight(node.handleHeight, node.height, v), + }) } precision={2} restoreOnCommit={false} diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index ed108ccd8..78722bfa6 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -2,13 +2,13 @@ import { type AlignmentAnchor, type AnyNode, type AnyNodeId, + collectAlignmentAnchors, type DragAction, type FenceNode, resolveAlignment, useAlignmentGuides, useScene, type WallNode, - wallSegmentAnchors, } from '@pascal-app/core' import { type FencePlanPoint, @@ -143,12 +143,9 @@ export const moveFenceEndpointDragAction: DragAction f.id !== fence.id), - ] - const alignCandidates = alignSegments.flatMap((s) => wallSegmentAnchors(s.id, s.start, s.end)) + // Alignment targets — anchors of every other alignable object (walls, + // fences, items, slabs, ceilings, columns). + const alignCandidates = collectAlignmentAnchors(useScene.getState().nodes, fence.id) return { fenceId: fence.id as AnyNodeId, diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index 6750289bb..c56f4c2ab 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -21,7 +21,7 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { moveFenceEndpointDragAction } from './actions/move-endpoint' /** @@ -125,6 +125,19 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const liveEnd = liveFence?.end ?? target.fence.end const movingPoint = endpoint === 'start' ? liveStart : liveEnd + // Ticker SFX on each grid-snap step, mirroring the wall endpoint tool. + // The action snaps the point before writing to the scene, so `movingPoint` + // only changes in discrete grid steps — the right cadence for the click. + // First tick just seeds the ref (no sound on mount). + const previousGridPosRef = useRef(null) + useEffect(() => { + const prev = previousGridPosRef.current + if (prev && (prev[0] !== movingPoint[0] || prev[1] !== movingPoint[1])) { + triggerSFX('sfx:grid-snap') + } + previousGridPosRef.current = movingPoint + }, [movingPoint]) + // Neighbour segments at the parent level — computed once at mount. const parentId = target.fence.parentId ?? null const neighbourSegments = useMemo(() => { diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index eb5d18d51..08b75bf3f 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -9,12 +9,14 @@ import { itemFloorplanMoveTarget } from './floorplan-move' import { itemParametrics } from './parametrics' import { ItemNode } from './schema' -// Gizmo sits just past the front-right footprint corner; the guide ring +// The two floor gizmos flank the item at mid-height so they never overlap, +// even on small items: move sits past the left edge, rotate past the right +// edge, both floated the same distance in front of the item. Mirrors the +// wall-item layout below (WALL_SIDE_OFFSET / WALL_GIZMO_LIFT). The guide ring // traces a circle slightly outside the footprint's bounding circle. -const ROTATE_CORNER_OFFSET = 0.25 +const GIZMO_SIDE_OFFSET = 0.3 +const GIZMO_FRONT_OFFSET = 0.3 const ROTATE_RING_OFFSET = 0.06 -// How far past the item's front edge the move cross floats. -const MOVE_FRONT_OFFSET = 0.35 // Whole-item rotation handle — the two-headed curved arrow. `arc-resize` // does the angular drag math (raycasts a horizontal plane at the gizmo's @@ -35,12 +37,12 @@ function itemRotateHandle(): HandleDescriptor { return { rotation: [rx, ry - delta, rz] } }, placement: { - // Front-right corner of the footprint at mid-height. The registered - // item mesh carries position + rotation only (scale lives on an - // inner mesh), so the scaled footprint maps straight to world. + // Past the item's right edge at mid-height, floated in front. The + // registered item mesh carries position + rotation only (scale lives on + // an inner mesh), so the scaled footprint maps straight to world. position: (n) => { const [w, h, d] = getScaledDimensions(n) - return [w / 2, h / 2, d / 2 + ROTATE_CORNER_OFFSET] + return [w / 2 + GIZMO_SIDE_OFFSET, h / 2, d / 2 + GIZMO_FRONT_OFFSET] }, // Fixed −45° tilt leans the curve toward the item's front face. rotationY: () => -Math.PI / 4, @@ -56,27 +58,24 @@ function itemRotateHandle(): HandleDescriptor { } } -// Free ground-plane move gizmo — the 4-way cross just outside the front edge. -// Press-drag-release slides the item across the floor (live preview, commit -// on release). `snapExtents` aligns the item's edges to the grid the same -// way placement does, swapping width / depth at 90° turns. +// The 4-way move cross, just past the item's left edge. Press-drag hands the +// item to its placement coordinator (showing the bounding box, dimension labels +// and grid-snap ticker) and commits on release — press-drag-release motion with +// the full placement feedback. function itemMoveHandle(): HandleDescriptor { return { - kind: 'translate', + kind: 'tap-action', + shape: 'move-cross', + cursor: 'move', + onActivate: (node, _scene, editor) => editor.engageMoveDrag(node), placement: { - // Sit just outside the item's front edge (centred in X, clear of the - // model), low to the floor so it reads as a floor-move grip. + // Past the item's left edge at mid-height, mirroring the rotate grip on + // the right so the two never overlap on small items. position: (n) => { - const [, , d] = getScaledDimensions(n) - return [0, 0.02, d / 2 + MOVE_FRONT_OFFSET] + const [w, h, d] = getScaledDimensions(n) + return [-(w / 2 + GIZMO_SIDE_OFFSET), h / 2, d / 2 + GIZMO_FRONT_OFFSET] }, }, - apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), - snapExtents: (n) => { - const [dimX, , dimZ] = getScaledDimensions(n) - const swap = Math.abs(Math.sin(n.rotation[1] ?? 0)) > 0.9 - return [swap ? dimZ : dimX, swap ? dimX : dimZ] - }, } } @@ -113,27 +112,25 @@ function itemWallRotateHandle(): HandleDescriptor { } } -// Slide the item across the wall face — constrained to the wall plane (along -// the wall + up/down), depth pinned. Sits just past the item's left edge. +// Move cross past the item's left edge on the wall face. Tap to hand the item +// to its placement coordinator (`engageMove`) — same feedback as the floating +// move button, and the coordinator handles the wall ↔ floor ↔ ceiling +// transitions the generic translate drag couldn't. `plane: 'node-normal'` +// stands the cross up against the wall face. function itemWallMoveHandle(): HandleDescriptor { return { - kind: 'translate', + kind: 'tap-action', + shape: 'move-cross', plane: 'node-normal', portal: 'grandparent', + cursor: 'move', + onActivate: (node, _scene, editor) => editor.engageMoveDrag(node), placement: { position: (n) => { const [w] = getScaledDimensions(n) return [-(w / 2 + WALL_SIDE_OFFSET), 0, WALL_GIZMO_LIFT] }, }, - apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), - snapExtents: (n) => { - const [dimX, dimY] = getScaledDimensions(n) - // A 90° roll about the normal swaps the item's along-wall + vertical - // footprint. - const swap = Math.abs(Math.sin(n.rotation[2] ?? 0)) > 0.9 - return [swap ? dimY : dimX, swap ? dimX : dimY] - }, } } diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 5b4c2ccc2..11f7356ac 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -1,14 +1,17 @@ import { type AnyNodeId, + collectAlignmentAnchors, emitter, type FenceNode, type GridEvent, type LevelNode, type RoofNode, type RoofSegmentNode, + resolveAlignment, type StairNode, type StairSegmentNode, sceneRegistry, + useAlignmentGuides, useLiveTransforms, useScene, type WallNode, @@ -25,6 +28,9 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' +/** Figma-style alignment-snap threshold (meters), matching the other tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode }> = ({ node: movingNode }) => { @@ -158,6 +164,29 @@ export const MoveRoofTool: React.FC<{ const buildingId = useViewer.getState().selection.buildingId const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + // Alignment for top-level stair / roof only. Segments live in parent-local + // space (a different frame from the building-local candidate pool / guide + // layer), so we leave them on the plain grid+corner snap. The moving node + // is aligned by its ORIGIN point (how this tool positions it), snapped to + // any other alignable object's anchors. + const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof' + const alignmentCandidates = alignTopLevel + ? collectAlignmentAnchors(useScene.getState().nodes, movingNode.id) + : [] + const alignLocalPoint = (lx: number, lz: number, bypass: boolean): [number, number] => { + if (!alignTopLevel || bypass || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return [lx, lz] + } + const ar = resolveAlignment({ + moving: [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }], + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + useAlignmentGuides.getState().set(ar.guides) + return ar.snap ? [lx + ar.snap.dx, lz + ar.snap.dz] : [lx, lz] + } + const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => { if (buildingObj) { const worldPoint = buildingObj.localToWorld( @@ -213,7 +242,15 @@ export const MoveRoofTool: React.FC<{ walls: levelWalls, fences: levelFences, }) - const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y) + // Layer alignment snap on top (top-level stair/roof). Recompute the + // world point from the aligned building-local point so it stays correct + // under building rotation. + const [lx, lz] = alignLocalPoint( + snappedLocal[0], + snappedLocal[1], + event.nativeEvent?.altKey === true, + ) + const [gridX, , gridZ] = localToWorldPoint([lx, lz], y) if ( previousGridPosRef.current && @@ -223,7 +260,6 @@ export const MoveRoofTool: React.FC<{ } previousGridPosRef.current = [gridX, gridZ] - const [lx, lz] = snappedLocal setCursorWorldPos([lx, event.localPosition[1], lz]) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) @@ -249,11 +285,16 @@ export const MoveRoofTool: React.FC<{ walls: levelWalls, fences: levelFences, }) - const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y) - const [lx, lz] = snappedLocal + const [lx, lz] = alignLocalPoint( + snappedLocal[0], + snappedLocal[1], + event.nativeEvent?.altKey === true, + ) + const [gridX, , gridZ] = localToWorldPoint([lx, lz], y) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) + useAlignmentGuides.getState().clear() wasCommitted = true // The store still holds the original values (we didn't update during drag). @@ -286,6 +327,7 @@ export const MoveRoofTool: React.FC<{ const onCancel = () => { wasCancelled = true useLiveTransforms.getState().clear(movingNode.id) + useAlignmentGuides.getState().clear() if (isNew) { useScene.getState().deleteNode(movingNode.id) } else { @@ -340,8 +382,9 @@ export const MoveRoofTool: React.FC<{ if (segmentWrapperGroup) segmentWrapperGroup.visible = false if (mergedRoofMesh) mergedRoofMesh.visible = true - // Clear ephemeral live transform + // Clear ephemeral live transform + any alignment guides useLiveTransforms.getState().clear(movingNode.id) + useAlignmentGuides.getState().clear() // Skip restore when the 2D floor-plan overlay claimed teardown // ownership — same contract `FloorplanRegistryMoveOverlay` uses to diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index 7dac9c3b8..4bbb9bec4 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -2,12 +2,16 @@ import { type AnyNodeId, + collectAlignmentAnchors, emitter, type FenceNode, type GridEvent, type LevelNode, + polygonAnchors, + resolveAlignment, type SlabNode, sceneRegistry, + useAlignmentGuides, useLiveTransforms, useScene, type WallNode, @@ -40,6 +44,9 @@ import type * as THREE from 'three' * nothing for zundo to record. The single `scene.update` on commit * becomes the single undo step naturally. */ +/** Figma-style alignment-snap threshold (meters), matching the other tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + function translatePolygon( polygon: Array<[number, number]>, deltaX: number, @@ -125,6 +132,10 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) .filter((child): child is FenceNode => child?.type === 'fence') + // Alignment candidates — every other alignable object's anchors, + // gathered once (the scene graph is stable during the drag). + const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, slabId) + let wasCommitted = false const applyPreview = (deltaX: number, deltaZ: number) => { @@ -170,7 +181,29 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { const anchor = dragAnchorRef.current ?? [localX, localZ] dragAnchorRef.current = anchor - applyPreview(localX - anchor[0], localZ - anchor[1]) + let deltaX = localX - anchor[0] + let deltaZ = localZ - anchor[1] + + // Figma-style alignment snap: align the slab's translated polygon + // vertices to other objects' anchors; fold the snap into the delta and + // publish a guide. Alt bypasses. + const bypass = event.nativeEvent?.altKey === true + if (!bypass && alignmentCandidates.length > 0) { + const result = resolveAlignment({ + moving: polygonAnchors(slabId, translatePolygon(originalPolygon, deltaX, deltaZ)), + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + deltaX += result.snap.dx + deltaZ += result.snap.dz + } + useAlignmentGuides.getState().set(result.guides) + } else { + useAlignmentGuides.getState().clear() + } + + applyPreview(deltaX, deltaZ) } const onGridClick = (event: GridEvent) => { @@ -197,6 +230,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { // GeometrySystem rebuild zeros it on the next frame, by which // point the new geometry is in place — visual stays smooth. useLiveTransforms.getState().clear(slabId) + useAlignmentGuides.getState().clear() triggerSFX('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [slabId] }) @@ -208,6 +242,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { // No scene state to roll back — we never wrote anything. Just // restore the mesh visual. clearPreview() + useAlignmentGuides.getState().clear() useViewer.getState().setSelection({ selectedIds: [slabId] }) markToolCancelConsumed() exitMoveMode() @@ -218,6 +253,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { emitter.on('tool:cancel', onCancel) return () => { + useAlignmentGuides.getState().clear() if (!wasCommitted) { clearPreview() } else { diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index b2b233662..e37ce12ec 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, + collectAlignmentAnchors, DEFAULT_WALL_HEIGHT, emitter, type GridEvent, @@ -13,7 +14,6 @@ import { useAlignmentGuides, useScene, type WallNode, - wallSegmentAnchors, } from '@pascal-app/core' import { CursorSphere, @@ -214,23 +214,11 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), ) - // Alignment candidates — endpoints + midpoints of every OTHER wall AND - // fence on this level (both share the start/end segment shape), gathered - // once (the set is stable during the drag). Coords are building-local, - // the same frame as the cursor and the 3D guide layer, so the published - // marker lines up. - const parentId = target.wall.parentId ?? null - const alignSegments: { id: string; start: WallPlanPoint; end: WallPlanPoint }[] = [] - for (const segment of Object.values(useScene.getState().nodes)) { - if (!segment || segment.id === nodeId) continue - if ((segment.parentId ?? null) !== parentId) continue - if (segment.type === 'wall' || segment.type === 'fence') { - alignSegments.push({ id: segment.id, start: segment.start, end: segment.end }) - } - } - const wallAlignmentCandidates = alignSegments.flatMap((segment) => - wallSegmentAnchors(segment.id, segment.start, segment.end), - ) + // Alignment candidates — anchors of every OTHER alignable object (walls, + // fences, items, slabs, ceilings, columns), gathered once (the set is + // stable during the drag). Coords are building-local, the same frame as + // the cursor and the 3D guide layer, so the published guide lines up. + const wallAlignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, nodeId) pauseSceneHistory(useScene) let wasCommitted = false From 00e9919d6ece1837d1aec772fa9d3b74d9b8ff15 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 01:38:02 +0530 Subject: [PATCH 06/17] fix(editor): keep autosave alive across page unload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autosave debounces writes by 1s and relies on a `beforeunload` flush for anything still pending. That flush fired a plain `fetch` PUT, which the browser cancels the instant the page unloads — so refreshing right after an edit (e.g. painting a roof material) silently dropped the change and the reload showed the last persisted scene. Thread a `{ keepalive }` option through the save callback and set it on the unload flush so the request survives the unload. Also listen for `pagehide` (fires where `beforeunload` does not, e.g. mobile Safari / bfcache) and clear the dirty flag up front so the two listeners don't double-send. Normal debounced saves omit `keepalive` (its 64KB body cap only constrains the best-effort unload flush, not regular saves). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/editor/components/scene-loader.tsx | 7 ++++++- packages/editor/src/components/editor/index.tsx | 2 +- packages/editor/src/hooks/use-auto-save.ts | 13 ++++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index a3706740e..0a602be11 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -102,7 +102,7 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { const handleLoad = useCallback(async () => initialScene, [initialScene]) const handleSave = useCallback( - async (graph: SceneGraph) => { + async (graph: SceneGraph, options?: { keepalive?: boolean }) => { const graphJson = sceneGraphSignature(graph) const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current if (lastRemoteGraphJsonRef.current === graphJson) { @@ -120,6 +120,11 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { 'If-Match': String(versionRef.current), }, body: JSON.stringify({ name: meta.name, graph }), + // `keepalive` lets the request outlive a page unload (the autosave + // flush on refresh/close). Browsers cap keepalive bodies at 64KB, so + // only the unload flush opts in — normal debounced saves omit it and + // can carry arbitrarily large scenes. + keepalive: options?.keepalive, }) if (response.status === 409) { diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index cd8243f4b..9fb83be78 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -139,7 +139,7 @@ export interface EditorProps { // Persistence — defaults to localStorage when omitted onLoad?: () => Promise - onSave?: (scene: SceneGraph) => Promise + onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index 43af28f7b..fd4acf3ac 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -9,7 +9,7 @@ const AUTOSAVE_DEBOUNCE_MS = 1000 export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error' interface UseAutoSaveOptions { - onSave?: (scene: SceneGraph) => Promise + onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise onDirty?: () => void onSaveStatusChange?: (status: SaveStatus) => void isVersionPreviewMode?: boolean @@ -148,23 +148,30 @@ export function useAutoSave({ }, AUTOSAVE_DEBOUNCE_MS) }) + // Flush any unsaved change while the page is going away. The network + // save MUST set `keepalive` — a normal fetch is cancelled by the browser + // the moment the page unloads, so a quick refresh right after an edit + // would otherwise drop the change entirely. `pagehide` fires in cases + // (mobile Safari, bfcache) where `beforeunload` does not. function flushOnExit() { if (!hasDirtyChangesRef.current) return + hasDirtyChangesRef.current = false const { nodes, rootNodeIds } = useScene.getState() const sceneGraph = { nodes, rootNodeIds } as SceneGraph if (onSaveRef.current) { - onSaveRef.current(sceneGraph).catch(() => {}) + onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {}) } else { saveSceneToLocalStorage(sceneGraph) } - hasDirtyChangesRef.current = false } window.addEventListener('beforeunload', flushOnExit) + window.addEventListener('pagehide', flushOnExit) return () => { executeSaveRef.current = null window.removeEventListener('beforeunload', flushOnExit) + window.removeEventListener('pagehide', flushOnExit) if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current) flushOnExit() unsubscribe() From 1f9756eb7058ae57b5d5fa91a871d706ed215c62 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 01:38:22 +0530 Subject: [PATCH 07/17] feat(editor): paint eraser + reset-all, drop roof cross-role bleed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Material paint gains an eraser (clear a surface back to its default) and a "Reset all" action that defaults every painted surface on a node — for a roof that includes each child segment — via a generic `buildResetSurfaceMaterialUpdates` that nulls catch-all and role-specific material fields without per-kind knowledge. Also stop a single painted roof surface from bleeding onto the others: `getEffectiveRoofSurfaceMaterial`, `getRoofMaterialArray`, and the segment renderer no longer cross-fall-back between top/edge/wall. An unset role resolves only to the legacy catch-all (back-compat) or the theme default, so painting just the shingle, trim, or soffit stays on that surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/schema/nodes/roof.ts | 24 +--- .../components/editor/selection-manager.tsx | 106 ++++++++++-------- .../ui/controls/material-paint-panel.tsx | 43 ++++++- packages/editor/src/index.tsx | 1 + packages/editor/src/lib/material-paint.ts | 57 ++++++---- packages/editor/src/store/use-editor.tsx | 11 +- packages/nodes/src/roof-segment/renderer.tsx | 9 +- .../viewer/src/systems/roof/roof-materials.ts | 13 ++- 8 files changed, 159 insertions(+), 105 deletions(-) diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index 0dd2f39a5..89d806b28 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -81,25 +81,9 @@ export function getEffectiveRoofSurfaceMaterial( } } - if (role === 'edge') { - if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { - return { - material: node.wallMaterial, - materialPreset: - typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined, - } - } - } - - if (role === 'wall') { - if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { - return { - material: node.edgeMaterial, - materialPreset: - typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined, - } - } - } - + // No cross-role fallback: an unset role resolves only to the legacy + // catch-all (which covers all three roles for back-compat) and otherwise + // to the caller's theme default. Painting one surface must never bleed + // onto the others. return getLegacyRoofSurfaceMaterial(node) } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 752e3b058..bf8af2d35 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -45,7 +45,6 @@ import { type ActivePaintMaterial, buildRoofSegmentSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch, - buildRoofSurfaceMaterialUpdates, buildSingleSurfaceMaterialPatch, buildStairSurfaceMaterialPatch, hasActivePaintMaterial, @@ -294,12 +293,9 @@ function applyRoofSegmentPaintPreview( if (!(edge || wall || top)) return null const fallback = parent ? getRoofMaterialArray(parent) : null const fb = (n: number) => fallback?.[n] ?? null - const arr: Material[] = [ - edge ?? wall ?? top ?? fb(0)!, - wall ?? edge ?? top ?? fb(1)!, - wall ?? edge ?? top ?? fb(2)!, - top ?? wall ?? edge ?? fb(3)!, - ] + // Per-role only, then the parent's themed slot — matches the renderer so the + // preview never bleeds a painted surface onto the segment's other surfaces. + const arr: Material[] = [edge ?? fb(0)!, wall ?? fb(1)!, wall ?? fb(2)!, top ?? fb(3)!] if (arr.some((m) => !m)) return null return previewMeshMaterial(mesh, arr) } @@ -844,11 +840,31 @@ export const SelectionManager = () => { }) const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => { + const eraser = useEditor.getState().paintEraser const activePaintMaterial = resolveActivePaintMaterial() const node = event.node if (!isNodeInCurrentLevel(node)) return null + // The eraser clears a surface back to its default by painting with an + // empty material — every `build*SurfaceMaterialPatch` interprets + // `undefined` material/preset as "reset this role". So a single spec + // with both fields undefined drives the same apply/preview paths as a + // real material; only the enabled-gate differs (no material required). + const paintEnabled = eraser || hasActivePaintMaterial(activePaintMaterial) + const paintSpec: ActivePaintMaterial = eraser + ? { + material: undefined, + materialPreset: undefined, + sourceTarget: + activePaintMaterial?.sourceTarget ?? useEditor.getState().activePaintTarget, + } + : (activePaintMaterial ?? { + material: undefined, + materialPreset: undefined, + sourceTarget: useEditor.getState().activePaintTarget, + }) + // Registry-driven paint dispatch — kinds that declare // `capabilities.paint` route hover / click / preview through // their definition. Wall, chimney, and dormer use this; legacy @@ -864,9 +880,9 @@ export const SelectionManager = () => { localPosition: event.localPosition as readonly [number, number, number] | undefined, hitObjectName: event.nativeEvent.object?.name, }) - const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial) + const compatible = role !== null && paintEnabled return { - key: `${node.type}:${node.id}:${role ?? 'unsupported'}`, + key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, hoveredId: node.id as AnyNodeId, hoverMode: compatible ? 'paint-ready' : 'paint-disabled', apply: @@ -877,8 +893,8 @@ export const SelectionManager = () => { paintCap.buildPatch({ node, role, - material: activePaintMaterial.material, - materialPreset: activePaintMaterial.materialPreset, + material: paintSpec.material, + materialPreset: paintSpec.materialPreset, }) as Partial, ) } @@ -891,8 +907,8 @@ export const SelectionManager = () => { return paintCap.applyPreview({ node, role, - material: activePaintMaterial.material, - materialPreset: activePaintMaterial.materialPreset, + material: paintSpec.material, + materialPreset: paintSpec.materialPreset, root, }) } @@ -911,7 +927,7 @@ export const SelectionManager = () => { if (!roofNode || roofNode.type !== 'roof') return null const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent) - const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial) + const compatible = role !== null && paintEnabled // Painting directly on a segment (only possible in segment edit // mode, where the per-segment mesh is visible) writes to the // segment's own role-specific fields. Painting the merged shell @@ -920,14 +936,11 @@ export const SelectionManager = () => { return { key: `${segmentTarget ? 'roof-segment' : 'roof'}:${ segmentTarget ? segmentTarget.id : roofNode.id - }:${role ?? 'unsupported'}`, + }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId, - hoverMode: - compatible && hasActivePaintMaterial(activePaintMaterial) && role - ? 'paint-ready' - : 'paint-disabled', + hoverMode: compatible ? 'paint-ready' : 'paint-disabled', apply: - compatible && hasActivePaintMaterial(activePaintMaterial) + compatible && role ? () => { const sceneState = useScene.getState() if (segmentTarget) { @@ -935,35 +948,35 @@ export const SelectionManager = () => { segmentTarget.id as AnyNodeId, buildRoofSegmentSurfaceMaterialPatch( segmentTarget, - role!, - activePaintMaterial.material, - activePaintMaterial.materialPreset, + role, + paintSpec.material, + paintSpec.materialPreset, ), ) } else { - sceneState.updateNodes( - buildRoofSurfaceMaterialUpdates( - sceneState.nodes, + sceneState.updateNode( + roofNode.id as AnyNodeId, + buildRoofSurfaceMaterialPatch( roofNode as RoofNode, - role!, - activePaintMaterial.material, - activePaintMaterial.materialPreset, + role, + paintSpec.material, + paintSpec.materialPreset, ), ) } } : null, preview: - compatible && hasActivePaintMaterial(activePaintMaterial) && role + compatible && role ? () => segmentTarget ? applyRoofSegmentPaintPreview( segmentTarget, roofNode as RoofNode, role, - activePaintMaterial, + paintSpec, ) - : applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial) + : applyRoofPaintPreview(roofNode as RoofNode, role, paintSpec) : () => previewCursor('not-allowed'), } } @@ -978,16 +991,13 @@ export const SelectionManager = () => { if (!stairNode || stairNode.type !== 'stair') return null const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent) - const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial) + const compatible = role !== null && paintEnabled return { - key: `stair:${stairNode.id}:${role ?? 'unsupported'}`, + key: `stair:${stairNode.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, hoveredId: stairNode.id as AnyNodeId, - hoverMode: - compatible && hasActivePaintMaterial(activePaintMaterial) && role - ? 'paint-ready' - : 'paint-disabled', + hoverMode: compatible ? 'paint-ready' : 'paint-disabled', apply: - compatible && hasActivePaintMaterial(activePaintMaterial) + compatible && role ? () => { useScene .getState() @@ -995,16 +1005,16 @@ export const SelectionManager = () => { stairNode.id as AnyNodeId, buildStairSurfaceMaterialPatch( stairNode as StairNode, - role!, - activePaintMaterial.material, - activePaintMaterial.materialPreset, + role, + paintSpec.material, + paintSpec.materialPreset, ), ) } : null, preview: - compatible && hasActivePaintMaterial(activePaintMaterial) && role - ? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial) + compatible && role + ? () => applyStairPaintPreview(stairNode as StairNode, role, paintSpec) : () => previewCursor('not-allowed'), } } @@ -1021,10 +1031,10 @@ export const SelectionManager = () => { node.type === 'ceiling' || node.type === 'shelf' ) { - const compatible = hasActivePaintMaterial(activePaintMaterial) + const compatible = paintEnabled return { - key: `${node.type}:${node.id}:surface`, + key: `${node.type}:${node.id}:surface:${eraser ? 'erase' : 'paint'}`, hoveredId: node.id as AnyNodeId, hoverMode: compatible ? 'paint-ready' : 'paint-disabled', apply: compatible @@ -1035,7 +1045,7 @@ export const SelectionManager = () => { node.id as AnyNodeId, buildSingleSurfaceMaterialPatch< FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode - >(activePaintMaterial.material, activePaintMaterial.materialPreset), + >(paintSpec.material, paintSpec.materialPreset), ) } : null, @@ -1043,7 +1053,7 @@ export const SelectionManager = () => { ? () => applySingleSurfacePaintPreview( node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode, - activePaintMaterial, + paintSpec, ) : () => previewCursor('not-allowed'), } diff --git a/packages/editor/src/components/ui/controls/material-paint-panel.tsx b/packages/editor/src/components/ui/controls/material-paint-panel.tsx index 1507cc9c8..eead7524f 100644 --- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx +++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx @@ -1,10 +1,15 @@ 'use client' -import { useScene } from '@pascal-app/core' +import { type AnyNodeId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { Eraser, RotateCcw } from 'lucide-react' import { useEffect } from 'react' -import { resolvePaintTargetFromSelection } from './../../../lib/material-paint' +import { + buildResetSurfaceMaterialUpdates, + resolvePaintTargetFromSelection, +} from './../../../lib/material-paint' import useEditor from './../../../store/use-editor' +import { Button } from '../primitives/button' import { MaterialPicker } from './material-picker' /** @@ -18,9 +23,14 @@ export function MaterialPaintPanel() { const activePaintTarget = useEditor((state) => state.activePaintTarget) const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget) + const paintEraser = useEditor((state) => state.paintEraser) + const setPaintEraser = useEditor((state) => state.setPaintEraser) const selectedIds = useViewer((state) => state.selection.selectedIds) const nodes = useScene((state) => state.nodes) const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null + const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null + const canResetSelection = + selectedNode != null && resolvePaintTargetFromSelection({ nodes, selectedId }) != null useEffect(() => { const selectedPaintTarget = resolvePaintTargetFromSelection({ nodes, selectedId }) @@ -29,8 +39,35 @@ export function MaterialPaintPanel() { } }, [nodes, selectedId, setActivePaintTarget]) + const resetSelection = () => { + if (!selectedNode) return + useScene.getState().updateNodes(buildResetSurfaceMaterialUpdates(nodes, selectedNode)) + } + return ( -
+
+
+ + +
{ setActivePaintMaterial({ material, sourceTarget: activePaintTarget }) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 54d22c76b..518165a9f 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -185,6 +185,7 @@ export { getFloorplanWallThickness, } from './lib/floorplan' export { + buildResetSurfaceMaterialUpdates, buildRoofSurfaceMaterialPatch, buildSingleSurfaceMaterialPatch, buildStairSurfaceMaterialPatch, diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index a2015966f..fbd592718 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -160,34 +160,45 @@ export function buildRoofSegmentSurfaceMaterialPatch( } } -export function buildRoofSurfaceMaterialUpdates( +/** + * Clear every painted material on a node back to its default. Works for any + * kind without per-type knowledge: it nulls the catch-all `material` / + * `materialPreset` plus any role field (`*Material` / `*MaterialPreset`) that + * the node actually carries. For a roof it also resets every child segment, so + * a single call defaults the whole roof system. `updateNode` merges patches + * shallowly without re-validation, so the `undefined` values land as cleared + * fields and the renderer falls back to the theme defaults. + */ +export function buildResetSurfaceMaterialUpdates( nodes: Record, - node: RoofNode, - targetRole: RoofSurfaceMaterialRole, - material: MaterialSchema | undefined, - materialPreset: string | undefined, + node: AnyNode, ): { id: AnyNodeId; data: Partial }[] { + const clearPatch = (target: AnyNode): Partial => { + const patch: Record = {} + for (const key of Object.keys(target)) { + if ( + key === 'material' || + key === 'materialPreset' || + key.endsWith('Material') || + key.endsWith('MaterialPreset') + ) { + patch[key] = undefined + } + } + return patch as Partial + } + const updates: { id: AnyNodeId; data: Partial }[] = [ - { - id: node.id as AnyNodeId, - data: buildRoofSurfaceMaterialPatch( - node, - targetRole, - material, - materialPreset, - ) as Partial, - }, + { id: node.id as AnyNodeId, data: clearPatch(node) }, ] - if (targetRole !== 'top') return updates - - for (const segmentId of node.children ?? []) { - const segment = nodes[segmentId as AnyNodeId] - if (segment?.type !== 'roof-segment') continue - updates.push({ - id: segment.id as AnyNodeId, - data: { material, materialPreset } as Partial as Partial, - }) + if (node.type === 'roof') { + for (const segmentId of (node as RoofNode).children ?? []) { + const segment = nodes[segmentId as AnyNodeId] + if (segment?.type === 'roof-segment') { + updates.push({ id: segment.id as AnyNodeId, data: clearPatch(segment) }) + } + } } return updates diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 2312c69ab..0c61bc82d 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -277,6 +277,10 @@ type EditorState = { setActivePaintMaterial: (material: ActivePaintMaterial | null) => void activePaintTarget: PaintableMaterialTarget setActivePaintTarget: (target: PaintableMaterialTarget) => void + // When true, clicking a surface in paint mode clears it back to its + // default material instead of applying `activePaintMaterial`. + paintEraser: boolean + setPaintEraser: (eraser: boolean) => void primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot hoveredPaintTarget: PaintableMaterialTarget | null setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void @@ -723,12 +727,17 @@ const useEditor = create()( selectedMaterialTarget: null, setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), activePaintMaterial: null, - setActivePaintMaterial: (material) => set({ activePaintMaterial: material }), + // Picking a material implies paint, not erase — clear the eraser so the + // next click applies the chosen material. + setActivePaintMaterial: (material) => + set({ activePaintMaterial: material, paintEraser: false }), activePaintTarget: 'wall', setActivePaintTarget: (target) => set((state) => state.activePaintTarget === target ? state : { activePaintTarget: target }, ), + paintEraser: false, + setPaintEraser: (eraser) => set({ paintEraser: eraser }), primeMaterialPaintFromSelection: () => { const selectedId = useViewer.getState().selection.selectedIds.length === 1 diff --git a/packages/nodes/src/roof-segment/renderer.tsx b/packages/nodes/src/roof-segment/renderer.tsx index f4d1fee8e..69d58acd6 100644 --- a/packages/nodes/src/roof-segment/renderer.tsx +++ b/packages/nodes/src/roof-segment/renderer.tsx @@ -88,13 +88,10 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { // Some slots have explicit materials; fill the rest from the themed array so // an untextured slot still picks up the scene-theme role colour, not blank white. + // Per-role only, then the themed parent slot — no cross-role fallback, so + // painting one segment surface never bleeds onto its other surfaces. const slot = (i: number) => themedArray?.[i] ?? new THREE.MeshStandardMaterial() - return [ - edge ?? wall ?? top ?? slot(0), - wall ?? edge ?? top ?? slot(1), - wall ?? edge ?? top ?? slot(2), - top ?? wall ?? edge ?? slot(3), - ] as THREE.Material[] + return [edge ?? slot(0), wall ?? slot(1), wall ?? slot(2), top ?? slot(3)] as THREE.Material[] }, [ node.material, node.materialPreset, diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts index 8d0312313..e4040616c 100644 --- a/packages/viewer/src/systems/roof/roof-materials.ts +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -91,11 +91,16 @@ export function getRoofMaterialArray( return roleArray } + // Each slot resolves to its own role only, then the themed default — never + // another role. Cross-role fallback here used to splatter a single painted + // surface (e.g. the edge) across the shingle and soffit slots. The legacy + // catch-all still fills every role because `getEffectiveRoofSurfaceMaterial` + // returns it for top/edge/wall alike. const materialArray: RoofMaterialArray = [ - edgeMaterial ?? wallMaterial ?? topMaterial ?? roofMaterial, - wallMaterial ?? edgeMaterial ?? topMaterial ?? ceilingMaterial, - wallMaterial ?? edgeMaterial ?? topMaterial ?? ceilingMaterial, - topMaterial ?? wallMaterial ?? edgeMaterial ?? roofMaterial, + edgeMaterial ?? roofMaterial, + wallMaterial ?? ceilingMaterial, + wallMaterial ?? ceilingMaterial, + topMaterial ?? roofMaterial, ] roofMaterialArrayCache.set(cacheKey, materialArray) From 606007ee819f42578826df87d3d11eaff3c21c20 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 01:38:38 +0530 Subject: [PATCH 08/17] feat(editor): alignment guides + drag bounding box across tools Extend the Figma-style 3D alignment guides to the placement and move tools for columns, elevators, roofs, stairs, ceilings, slabs, fences, walls, doors, and windows: each collects alignment anchors from the scene, resolves a snap within the shared threshold, and drives the `useAlignmentGuides` overlay. Wall openings (doors/windows) only snap along their host wall via the new `wall-opening-alignment` helper. Add a shared `DragBoundingBox` overlay (exported from the editor barrel) that renders the dragged object's bounds during a move, wired into the column move tool alongside the alignment snap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/tools/column/column-tool.tsx | 67 +++++++- .../tools/elevator/elevator-tool.tsx | 65 +++++++- .../src/components/tools/roof/roof-tool.tsx | 66 +++++++- .../tools/shared/drag-bounding-box.tsx | 157 ++++++++++++++++++ .../src/components/tools/stair/stair-tool.tsx | 65 +++++++- packages/editor/src/index.tsx | 1 + packages/nodes/src/ceiling/tool.tsx | 66 +++++++- packages/nodes/src/column/move-tool.tsx | 22 ++- packages/nodes/src/door/move-tool.tsx | 37 ++++- packages/nodes/src/door/tool.tsx | 36 +++- packages/nodes/src/fence/tool.tsx | 72 +++++--- .../src/shared/wall-opening-alignment.ts | 110 ++++++++++++ packages/nodes/src/slab/tool.tsx | 66 +++++++- packages/nodes/src/wall/tool.tsx | 59 +++++-- packages/nodes/src/window/move-tool.tsx | 37 ++++- packages/nodes/src/window/tool.tsx | 42 ++++- 16 files changed, 890 insertions(+), 78 deletions(-) create mode 100644 packages/editor/src/components/tools/shared/drag-bounding-box.tsx create mode 100644 packages/nodes/src/shared/wall-opening-alignment.ts diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx index 837b1729b..98866f109 100644 --- a/packages/editor/src/components/tools/column/column-tool.tsx +++ b/packages/editor/src/components/tools/column/column-tool.tsx @@ -5,9 +5,12 @@ import { ColumnNode, type ColumnNode as ColumnNodeType, type ColumnPresetId, + collectAlignmentAnchors, emitter, type GridEvent, type LevelNode, + resolveAlignment, + useAlignmentGuides, useScene, } from '@pascal-app/core' import { useEffect, useRef, useState } from 'react' @@ -15,6 +18,9 @@ import type { Group } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' +/** Figma-style alignment-snap threshold (meters), matching the move tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + const COLUMN_ICON = ( // eslint-disable-next-line @next/next/no-img-element = ({ currentLevelId, onPlaced useEffect(() => { if (!currentLevelId) return + // Alignment candidates — anchors of every alignable object; refreshed + // after each placement so a newly-placed column is a target too. + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + // Snap the column origin onto another object's nearest real anchor and + // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped + // point: resolving against the grid point would only ever catch anchors + // that happen to sit on a grid line, so off-grid items (furniture, angled + // walls) would never surface a guide. The matched axis locks exactly to the + // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses. + const alignPoint = ( + gridX: number, + gridZ: number, + rawX: number, + rawZ: number, + bypass: boolean, + ): [number, number] => { + if (bypass || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return [gridX, gridZ] + } + const ar = resolveAlignment({ + moving: [{ nodeId: '__column-draft__', kind: 'corner', x: rawX, z: rawZ }], + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (ar.guides.length === 0) { + useAlignmentGuides.getState().clear() + return [gridX, gridZ] + } + useAlignmentGuides.getState().set(ar.guides) + let x = gridX + let z = gridZ + for (const guide of ar.guides) { + if (guide.axis === 'x') x = guide.coord + else z = guide.coord + } + return [x, z] + } + const onGridMove = (event: GridEvent) => { - const nextPosition: [number, number, number] = [ + const [ax, az] = alignPoint( roundToHalf(event.localPosition[0]), - 0, roundToHalf(event.localPosition[2]), - ] + event.localPosition[0], + event.localPosition[2], + event.nativeEvent?.altKey === true, + ) + const nextPosition: [number, number, number] = [ax, 0, az] setCursorPosition(nextPosition) cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2]) } const onGridClick = (event: GridEvent) => { - const position: [number, number, number] = [ + const [ax, az] = alignPoint( roundToHalf(event.localPosition[0]), - 0, roundToHalf(event.localPosition[2]), - ] - const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) + event.localPosition[0], + event.localPosition[2], + event.nativeEvent?.altKey === true, + ) + const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [ax, 0, az]) useScene.getState().createNode(column, currentLevelId) onPlaced?.(column.id) sfxEmitter.emit('sfx:structure-build') + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + useAlignmentGuides.getState().clear() } emitter.on('grid:move', onGridMove) @@ -77,6 +129,7 @@ export const ColumnTool: React.FC = ({ currentLevelId, onPlaced return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) + useAlignmentGuides.getState().clear() } }, [currentLevelId, onPlaced]) diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 4b81bd917..3dbe5113c 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -1,10 +1,13 @@ import { type AnyNodeId, type BuildingNode, + collectAlignmentAnchors, ElevatorNode, emitter, type GridEvent, type LevelNode, + resolveAlignment, + useAlignmentGuides, useScene, } from '@pascal-app/core' import { useEffect, useMemo, useRef } from 'react' @@ -24,6 +27,8 @@ import { } from './elevator-defaults' const GRID_OFFSET = 0.02 +/** Figma-style alignment-snap threshold (meters), matching the move tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 type ElevatorToolProps = { buildingId: BuildingNode['id'] | null @@ -130,9 +135,53 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, rotationRef.current = 0 if (previewRef.current) previewRef.current.rotation.y = 0 + // Alignment candidates — anchors of every alignable object; refreshed + // after each placement. The elevator aligns by its ORIGIN point. + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + // Snap the elevator origin onto another object's nearest real anchor and + // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped + // point: resolving against the grid point would only ever catch anchors + // that happen to sit on a grid line, so off-grid items (furniture, angled + // walls) would never surface a guide. The matched axis locks exactly to the + // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses. + const alignPoint = ( + gridX: number, + gridZ: number, + rawX: number, + rawZ: number, + bypass: boolean, + ): [number, number] => { + if (bypass || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return [gridX, gridZ] + } + const ar = resolveAlignment({ + moving: [{ nodeId: '__elevator-draft__', kind: 'corner', x: rawX, z: rawZ }], + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (ar.guides.length === 0) { + useAlignmentGuides.getState().clear() + return [gridX, gridZ] + } + useAlignmentGuides.getState().set(ar.guides) + let x = gridX + let z = gridZ + for (const guide of ar.guides) { + if (guide.axis === 'x') x = guide.coord + else z = guide.coord + } + return [x, z] + } + const onGridMove = (event: GridEvent) => { - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const [gridX, gridZ] = alignPoint( + Math.round(event.localPosition[0] * 2) / 2, + Math.round(event.localPosition[2] * 2) / 2, + event.localPosition[0], + event.localPosition[2], + event.nativeEvent?.altKey === true, + ) const supportY = resolveElevatorSupportY({ buildingId: currentBuildingId, preferredLevelId: levelId as LevelNode['id'] | null, @@ -161,8 +210,13 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, }) if (!latestBuildingId) return - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const [gridX, gridZ] = alignPoint( + Math.round(event.localPosition[0] * 2) / 2, + Math.round(event.localPosition[2] * 2) / 2, + event.localPosition[0], + event.localPosition[2], + event.nativeEvent?.altKey === true, + ) commitElevatorPlacement( latestBuildingId, levelId, @@ -171,6 +225,8 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, rotationRef.current, onPlaced, ) + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + useAlignmentGuides.getState().clear() } const onKeyDown = (event: KeyboardEvent) => { @@ -199,6 +255,7 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) window.removeEventListener('keydown', onKeyDown) + useAlignmentGuides.getState().clear() } }, [buildingId, levelId, onPlaced]) diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index d5c4fd234..277f56e2a 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -1,12 +1,15 @@ import { type AnyNode, type AnyNodeId, + collectAlignmentAnchors, emitter, type GridEvent, type LevelNode, RoofNode, RoofSegmentNode, + resolveAlignment, sceneRegistry, + useAlignmentGuides, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -22,6 +25,8 @@ import { CursorSphere } from '../shared/cursor-sphere' const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_PITCH_DEG = 40 const GRID_OFFSET = 0.02 +/** Figma-style alignment-snap threshold (meters), matching the move tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 /** * Creates a roof group with one default gable segment @@ -166,6 +171,45 @@ export const RoofTool: React.FC = () => { outlineRef.current.geometry = new BufferGeometry() + // Alignment candidates — anchors of every alignable object; refreshed + // after each roof commits. Both corners of the rectangle align. + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + // Snap the drafted corner onto another object's nearest real anchor and + // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped + // point: resolving against the grid point would only ever catch anchors + // that happen to sit on a grid line, so off-grid items (furniture, angled + // walls) would never surface a guide. The matched axis locks exactly to the + // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses. + const alignPoint = ( + gridX: number, + gridZ: number, + rawX: number, + rawZ: number, + bypass: boolean, + ): [number, number] => { + if (bypass || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return [gridX, gridZ] + } + const ar = resolveAlignment({ + moving: [{ nodeId: '__roof-draft__', kind: 'corner', x: rawX, z: rawZ }], + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (ar.guides.length === 0) { + useAlignmentGuides.getState().clear() + return [gridX, gridZ] + } + useAlignmentGuides.getState().set(ar.guides) + let x = gridX + let z = gridZ + for (const guide of ar.guides) { + if (guide.axis === 'x') x = guide.coord + else z = guide.coord + } + return [x, z] + } + const updateOutline = ( corner1: [number, number, number], corner2: [number, number, number], @@ -188,8 +232,13 @@ export const RoofTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const [gridX, gridZ] = alignPoint( + Math.round(event.localPosition[0] * 2) / 2, + Math.round(event.localPosition[2] * 2) / 2, + event.localPosition[0], + event.localPosition[2], + event.nativeEvent?.altKey === true, + ) const y = event.localPosition[1] const cursorPosition: [number, number, number] = [gridX, y, gridZ] @@ -221,8 +270,13 @@ export const RoofTool: React.FC = () => { const onGridClick = (event: GridEvent) => { if (!currentLevelId) return - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const [gridX, gridZ] = alignPoint( + Math.round(event.localPosition[0] * 2) / 2, + Math.round(event.localPosition[2] * 2) / 2, + event.localPosition[0], + event.localPosition[2], + event.nativeEvent?.altKey === true, + ) const y = event.localPosition[1] if (corner1Ref.current) { @@ -237,6 +291,8 @@ export const RoofTool: React.FC = () => { corner1Ref.current = null outlineRef.current.visible = false + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + useAlignmentGuides.getState().clear() } else { corner1Ref.current = [gridX, y, gridZ] setPreview((prev) => ({ @@ -253,6 +309,7 @@ export const RoofTool: React.FC = () => { outlineRef.current.visible = false setPreview((prev) => ({ ...prev, corner1: null })) } + useAlignmentGuides.getState().clear() } emitter.on('grid:move', onGridMove) @@ -263,6 +320,7 @@ export const RoofTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + useAlignmentGuides.getState().clear() corner1Ref.current = null } diff --git a/packages/editor/src/components/tools/shared/drag-bounding-box.tsx b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx new file mode 100644 index 000000000..16e4480f1 --- /dev/null +++ b/packages/editor/src/components/tools/shared/drag-bounding-box.tsx @@ -0,0 +1,157 @@ +'use client' + +import { sceneRegistry } from '@pascal-app/core' +import { useEffect, useMemo } from 'react' +import { + Box3, + BoxGeometry, + EdgesGeometry, + Matrix4, + type Mesh, + type Object3D, + PlaneGeometry, + Vector3, +} from 'three' +import { distance, smoothstep, uv, vec2 } from 'three/tsl' +import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../../lib/constants' + +const NO_RAYCAST = () => null + +/** green-500 — matches the item placement box's "placeable" state. */ +const DEFAULT_COLOR = 0x22_c5_5e + +type LocalBounds = { size: [number, number, number]; center: [number, number, number] } + +/** + * The node's bounding box in its OWN, unrotated frame — measured from the + * rendered geometry so it captures the full extent (base, cap, overhang), + * not just the declared width/height/depth. World position + rotation are + * cancelled out (invert the root's world matrix), so the result is stable + * regardless of where the live drag has moved the node; the caller re-applies + * the current Y rotation on the group. Returns null when nothing measurable. + */ +function measureLocalBounds(obj: Object3D): LocalBounds | null { + obj.updateWorldMatrix(true, true) + const inverseRoot = new Matrix4().copy(obj.matrixWorld).invert() + const box = new Box3() + const meshBox = new Box3() + const toLocal = new Matrix4() + let measured = false + obj.traverse((child) => { + const mesh = child as Mesh + if (!mesh.isMesh || !mesh.geometry) return + if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox() + const bb = mesh.geometry.boundingBox + if (!bb) return + toLocal.copy(inverseRoot).multiply(mesh.matrixWorld) + meshBox.copy(bb).applyMatrix4(toLocal) + box.union(meshBox) + measured = true + }) + if (!measured || box.isEmpty()) return null + const size = box.getSize(new Vector3()) + const center = box.getCenter(new Vector3()) + return { size: [size.x, size.y, size.z], center: [center.x, center.y, center.z] } +} + +interface DragBoundingBoxProps { + /** Node whose rendered geometry is measured for the box extents. */ + nodeId: string + /** Footprint origin on the floor, `[x, 0, z]` — the node's live position. */ + position: [number, number, number] + /** Y rotation (radians) applied to the box, matching the dragged node. */ + rotationY?: number + /** Declared `[width, height, depth]`, used until/if the mesh can't be measured. */ + fallbackSize?: [number, number, number] + color?: number +} + +/** + * Footprint box drawn around a node while it is being dragged — the same + * affordance items get during placement: a wireframe cube spanning the node's + * full measured extent plus a ground plane with a radial opacity gradient + * (transparent in the centre, opaque toward the edges). Overlay layer + + * `depthTest: false` keep it drawn on top of scene geometry throughout the + * drag, and the box visualises the bounds that drive alignment snapping. + */ +export function DragBoundingBox({ + nodeId, + position, + rotationY = 0, + fallbackSize = [0, 0, 0], + color = DEFAULT_COLOR, +}: DragBoundingBoxProps) { + const measured = useMemo(() => { + const obj = sceneRegistry.nodes.get(nodeId) + return obj ? measureLocalBounds(obj) : null + }, [nodeId]) + + const [w, h, d] = measured?.size ?? fallbackSize + const [cx, cy, cz] = measured?.center ?? [0, fallbackSize[1] / 2, 0] + const minY = cy - h / 2 + + const edgeGeometry = useMemo(() => { + const box = new BoxGeometry(w, h, d) + const edges = new EdgesGeometry(box) + box.dispose() + return edges + }, [w, h, d]) + + // Flat on the ground (XZ) at the box's base, nudged up 0.01m to avoid + // z-fighting with slabs. + const planeGeometry = useMemo(() => { + const plane = new PlaneGeometry(w, d) + plane.rotateX(-Math.PI / 2) + plane.translate(cx, minY + 0.01, cz) + return plane + }, [w, d, cx, minY, cz]) + + const edgeMaterial = useMemo( + () => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }), + [color], + ) + + const planeMaterial = useMemo(() => { + const material = new MeshBasicNodeMaterial({ + color, + transparent: true, + depthTest: false, + depthWrite: false, + }) + material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) + return material + }, [color]) + + useEffect( + () => () => { + edgeGeometry.dispose() + planeGeometry.dispose() + edgeMaterial.dispose() + planeMaterial.dispose() + }, + [edgeGeometry, planeGeometry, edgeMaterial, planeMaterial], + ) + + if (w <= 0 || h <= 0 || d <= 0) return null + + return ( + + + + + ) +} diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index c82711b9a..12b7b404b 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -1,9 +1,12 @@ import { + collectAlignmentAnchors, emitter, type GridEvent, type LevelNode, + resolveAlignment, StairNode, StairSegmentNode, + useAlignmentGuides, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -31,6 +34,8 @@ import { } from './stair-defaults' const GRID_OFFSET = 0.02 +/** Figma-style alignment-snap threshold (meters), matching the move tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 /** * Generates the step-profile geometry for the ghost preview. @@ -147,9 +152,53 @@ export const StairTool: React.FC = () => { rotationRef.current = 0 if (previewRef.current) previewRef.current.rotation.y = 0 + // Alignment candidates — anchors of every alignable object; refreshed + // after each placement. The stair aligns by its ORIGIN point. + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + // Snap the stair origin onto another object's nearest real anchor and + // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped + // point: resolving against the grid point would only ever catch anchors + // that happen to sit on a grid line, so off-grid items (furniture, angled + // walls) would never surface a guide. The matched axis locks exactly to the + // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses. + const alignPoint = ( + gridX: number, + gridZ: number, + rawX: number, + rawZ: number, + bypass: boolean, + ): [number, number] => { + if (bypass || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return [gridX, gridZ] + } + const ar = resolveAlignment({ + moving: [{ nodeId: '__stair-draft__', kind: 'corner', x: rawX, z: rawZ }], + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (ar.guides.length === 0) { + useAlignmentGuides.getState().clear() + return [gridX, gridZ] + } + useAlignmentGuides.getState().set(ar.guides) + let x = gridX + let z = gridZ + for (const guide of ar.guides) { + if (guide.axis === 'x') x = guide.coord + else z = guide.coord + } + return [x, z] + } + const onGridMove = (event: GridEvent) => { - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const [gridX, gridZ] = alignPoint( + Math.round(event.localPosition[0] * 2) / 2, + Math.round(event.localPosition[2] * 2) / 2, + event.localPosition[0], + event.localPosition[2], + event.nativeEvent?.altKey === true, + ) const y = event.localPosition[1] if (cursorRef.current) { @@ -173,9 +222,16 @@ export const StairTool: React.FC = () => { const onGridClick = (event: GridEvent) => { if (!currentLevelId) return - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const [gridX, gridZ] = alignPoint( + Math.round(event.localPosition[0] * 2) / 2, + Math.round(event.localPosition[2] * 2) / 2, + event.localPosition[0], + event.localPosition[2], + event.nativeEvent?.altKey === true, + ) commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current) + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + useAlignmentGuides.getState().clear() } const onKeyDown = (event: KeyboardEvent) => { @@ -206,6 +262,7 @@ export const StairTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) window.removeEventListener('keydown', onKeyDown) + useAlignmentGuides.getState().clear() } }, [currentLevelId]) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 518165a9f..61cf16109 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -59,6 +59,7 @@ export { usePlacementCoordinator, } from './components/tools/item/use-placement-coordinator' export { CursorSphere } from './components/tools/shared/cursor-sphere' +export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index 57f693490..7768d632c 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -1,6 +1,14 @@ 'use client' -import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' +import { + collectAlignmentAnchors, + emitter, + type GridEvent, + type LevelNode, + resolveAlignment, + useAlignmentGuides, + useScene, +} from '@pascal-app/core' import { CursorSphere, EDITOR_LAYER, @@ -25,6 +33,8 @@ import { CeilingNode } from './schema' const CEILING_HEIGHT = 2.52 const GRID_OFFSET = 0.02 +/** Figma-style alignment-snap threshold (meters), matching the move tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 function calculateSnapPoint( lastPoint: [number, number], @@ -83,6 +93,11 @@ export const CeilingTool: React.FC = () => { // draw isn't built with a stale preset's parameters. Unmount-only. useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), []) + // Clear alignment guides on unmount ONLY. The main drawing effect re-runs + // on every cursor move (cursorPosition is in its deps), so clearing guides + // in its cleanup would wipe the guide the instant after each move sets it. + useEffect(() => () => useAlignmentGuides.getState().clear(), []) + const verticalGeo = useMemo( () => new BufferGeometry().setFromPoints([ @@ -100,20 +115,60 @@ export const CeilingTool: React.FC = () => { useEffect(() => { if (!currentLevelId) return + // Alignment candidates — anchors of every OTHER alignable object. The + // ceiling's own in-progress vertices are intentionally excluded (no + // self-alignment while drawing). + const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + // Snap the drafted vertex onto another object's nearest real anchor and + // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped + // point: resolving against the grid point would only ever catch anchors + // that happen to sit on a grid line, so off-grid items (furniture, angled + // walls) would never surface a guide. The matched axis locks exactly to the + // candidate's coordinate; the other axis keeps its grid/ortho snap. Alt + // bypasses. + const alignPoint = ( + fallback: [number, number], + raw: [number, number], + bypass: boolean, + ): [number, number] => { + if (bypass || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return fallback + } + const ar = resolveAlignment({ + moving: [{ nodeId: '__ceiling-draft__', kind: 'corner', x: raw[0], z: raw[1] }], + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (ar.guides.length === 0) { + useAlignmentGuides.getState().clear() + return fallback + } + useAlignmentGuides.getState().set(ar.guides) + let [x, z] = fallback + for (const guide of ar.guides) { + if (guide.axis === 'x') x = guide.coord + else z = guide.coord + } + return [x, z] + } + const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && gridCursorRef.current)) return - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] + const gridX = Math.round(rawPoint[0] * 2) / 2 + const gridZ = Math.round(rawPoint[1] * 2) / 2 const gridPosition: [number, number] = [gridX, gridZ] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const ceilingY = event.localPosition[1] + CEILING_HEIGHT const gridY = event.localPosition[1] + GRID_OFFSET const lastPoint = points[points.length - 1] - const displayPoint = + const orthoPoint = shiftPressed.current || !lastPoint ? gridPosition : calculateSnapPoint(lastPoint, gridPosition) + const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true) setSnappedCursorPosition(displayPoint) if ( points.length > 0 && @@ -144,6 +199,7 @@ export const CeilingTool: React.FC = () => { const ceilingId = commitCeilingDrawing(currentLevelId, points) setSelection({ selectedIds: [ceilingId] }) setPoints([]) + useAlignmentGuides.getState().clear() } else { setPoints([...points, clickPoint]) } @@ -155,12 +211,14 @@ export const CeilingTool: React.FC = () => { const ceilingId = commitCeilingDrawing(currentLevelId, points) setSelection({ selectedIds: [ceilingId] }) setPoints([]) + useAlignmentGuides.getState().clear() } } const onCancel = () => { if (points.length > 0) markToolCancelConsumed() setPoints([]) + useAlignmentGuides.getState().clear() } const onKeyDown = (e: KeyboardEvent) => { diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index 25bf7165c..37cc905c3 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -14,7 +14,13 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { + CursorSphere, + DragBoundingBox, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' /** @@ -46,6 +52,7 @@ const ALIGNMENT_THRESHOLD_M = 0.08 function MoveColumnTool({ node }: { node: ColumnNode }) { const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) + const [previewRotation, setPreviewRotation] = useState(node.rotation) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -75,6 +82,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const applyPreview = (position: [number, number, number]) => { lastPosition = position setPreviewPosition(position) + setPreviewRotation(rotationY) useLiveTransforms.getState().set(node.id, { position, rotation: rotationY, @@ -197,7 +205,17 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { } }, [exitMoveMode, node]) - return + return ( + <> + + + + ) } export default MoveColumnTool diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 7953139d9..fd9e8d577 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -1,10 +1,12 @@ import { type AnyNodeId, + collectAlignmentAnchors, DoorNode, emitter, isCurvedWall, sceneRegistry, spatialGridManager, + useAlignmentGuides, useLiveTransforms, useScene, type WallEvent, @@ -15,7 +17,6 @@ import { EDITOR_LAYER, getSideFromNormal, isValidWallSideFace, - snapToHalf, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -23,6 +24,7 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' const edgeMaterial = new LineBasicNodeMaterial({ @@ -94,8 +96,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false + useAlignmentGuides.getState().clear() } + // Alignment candidates — anchors of every OTHER alignable object (the + // moving door is excluded so it never aligns to itself). + const alignmentCandidates = collectAlignmentAnchors( + useScene.getState().nodes, + movingDoorNode.id, + ) + const updateCursor = ( worldPosition: [number, number, number], cursorRotationY: number, @@ -131,7 +141,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) - const localX = snapToHalf(event.localPosition[0]) + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width: movingDoorNode.width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -190,7 +206,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) - const localX = snapToHalf(event.localPosition[0]) + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width: movingDoorNode.width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -255,7 +277,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const { side, itemRotation } = getPlacementOrientation(event) - const localX = snapToHalf(event.localPosition[0]) + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width: movingDoorNode.width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -395,6 +423,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } } useLiveTransforms.getState().clear(movingDoorNode.id) + useAlignmentGuides.getState().clear() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 85cb6431f..a00ff1877 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -1,10 +1,12 @@ import { type AnyNodeId, + collectAlignmentAnchors, DoorNode, emitter, isCurvedWall, sceneRegistry, spatialGridManager, + useAlignmentGuides, useScene, type WallEvent, } from '@pascal-app/core' @@ -14,13 +16,13 @@ import { EDITOR_LAYER, getSideFromNormal, isValidWallSideFace, - snapToHalf, triggerSFX, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' const edgeMaterial = new LineBasicNodeMaterial({ @@ -68,8 +70,13 @@ const DoorTool: React.FC = () => { const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false + useAlignmentGuides.getState().clear() } + // Alignment candidates — anchors of every alignable object; refreshed + // after each placement. A door aligns by the plan position of its centre. + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + const updateCursor = ( worldPosition: [number, number, number], cursorRotationY: number, @@ -100,9 +107,15 @@ const DoorTool: React.FC = () => { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) const width = 0.9 const height = 2.1 + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) @@ -147,9 +160,15 @@ const DoorTool: React.FC = () => { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) const width = draftRef.current?.width ?? 0.9 const height = draftRef.current?.height ?? 2.1 + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) @@ -212,7 +231,13 @@ const DoorTool: React.FC = () => { const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) - const localX = snapToHalf(event.localPosition[0]) + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width: draftRef.current.width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -279,6 +304,8 @@ const DoorTool: React.FC = () => { useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() triggerSFX('sfx:item-place') + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + useAlignmentGuides.getState().clear() event.stopPropagation() } @@ -302,6 +329,7 @@ const DoorTool: React.FC = () => { return () => { destroyDraft() hideCursor() + useAlignmentGuides.getState().clear() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 956a57b53..6d0716641 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -2,12 +2,15 @@ import { calculateLevelMiters, + collectAlignmentAnchors, emitter, type FenceNode, type GridEvent, getWallMiterBoundaryPoints, type LevelNode, type Point2D, + resolveAlignment, + useAlignmentGuides, useScene, type WallMiterData, type WallNode, @@ -35,6 +38,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 const FENCE_PREVIEW_HEIGHT = 1.8 const FENCE_PREVIEW_THICKNESS = 0.08 +/** Figma-style alignment-snap threshold (meters), matching the move tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 // HUD label heights are measured from the top of the preview bar, so they // track whatever height a seeded preset draws at (`previewHeight`). const DRAFT_LABEL_Y_OFFSET = 0.22 @@ -463,10 +468,34 @@ export const FenceTool: React.FC = () => { useEffect(() => { let previousFenceEnd: FencePlanPoint | null = null + // Alignment candidates — anchors of every alignable object. Refreshed + // after each segment commits (the new fence becomes a candidate too). + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + const refreshAlignmentCandidates = () => { + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + } + + // Align the drafted point onto another object's nearest real anchor and + // publish the guide. Alt bypasses. Returns the (possibly snapped) point. + const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => { + if (bypass || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return point + } + const ar = resolveAlignment({ + moving: [{ nodeId: '__fence-draft__', kind: 'corner', x: point[0], z: point[1] }], + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + useAlignmentGuides.getState().set(ar.guides) + return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point + } + const stopDrafting = () => { buildingState.current = 0 previewRef.current.visible = false setDraftMeasurement(null) + useAlignmentGuides.getState().clear() } const onGridMove = (event: GridEvent) => { @@ -476,14 +505,13 @@ export const FenceTool: React.FC = () => { // Default = active grid step; Shift switches to the fine step // (0.05m). No 45° angle snap — see `wall/tool.tsx` for rationale. const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined + const bypassAlign = event.nativeEvent?.altKey === true if (buildingState.current === 1) { - const snappedLocal = snapFenceDraftPoint({ - point: localPoint, - walls, - fences, - step, - }) + const snappedLocal = alignPoint( + snapFenceDraftPoint({ point: localPoint, walls, fences, step }), + bypassAlign, + ) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) cursorRef.current.position.copy(endingPoint.current) const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] @@ -513,7 +541,10 @@ export const FenceTool: React.FC = () => { ), ) } else { - const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences, step }) + const snappedPoint = alignPoint( + snapFenceDraftPoint({ point: localPoint, walls, fences, step }), + bypassAlign, + ) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) setDraftMeasurement(null) } @@ -528,26 +559,23 @@ export const FenceTool: React.FC = () => { const { walls, fences } = getCurrentLevelElements() const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined + const bypassAlign = event.nativeEvent?.altKey === true if (buildingState.current === 0) { - const snappedStart = snapFenceDraftPoint({ - point: localClick, - walls, - fences, - step: clickStep, - }) + const snappedStart = alignPoint( + snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }), + bypassAlign, + ) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) endingPoint.current.copy(startingPoint.current) buildingState.current = 1 previewRef.current.visible = true setDraftMeasurement(null) } else { - const snappedEnd = snapFenceDraftPoint({ - point: localClick, - walls, - fences, - step: clickStep, - }) + const snappedEnd = alignPoint( + snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }), + bypassAlign, + ) const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return @@ -557,6 +585,11 @@ export const FenceTool: React.FC = () => { ) if (!createdFence) return + // The new segment is now a real node — make it an alignment target + // for the next segment, and drop the just-shown guide. + refreshAlignmentCandidates() + useAlignmentGuides.getState().clear() + const nextStart = createdFence.end startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) endingPoint.current.copy(startingPoint.current) @@ -594,6 +627,7 @@ export const FenceTool: React.FC = () => { emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) + useAlignmentGuides.getState().clear() } }, [unit]) diff --git a/packages/nodes/src/shared/wall-opening-alignment.ts b/packages/nodes/src/shared/wall-opening-alignment.ts new file mode 100644 index 000000000..bda92e227 --- /dev/null +++ b/packages/nodes/src/shared/wall-opening-alignment.ts @@ -0,0 +1,110 @@ +import { + type AlignmentAnchor, + resolveAlignment, + useAlignmentGuides, + type WallNode, +} from '@pascal-app/core' +import { snapToHalf } from '@pascal-app/editor' + +/** Figma-style alignment-snap threshold (meters), matching the move tools. */ +export const WALL_OPENING_ALIGNMENT_THRESHOLD_M = 0.08 +/** + * A wall opening (door / window) can only slide ALONG its host wall, so it can + * only satisfy an x- or z-guide when the wall runs along that axis. Below this + * |component| (≈ wall within 60° of the axis) the along-wall move needed to + * reach the guide blows up, so we skip it rather than jump the opening across + * the wall. + */ +const MIN_AXIS_COMPONENT = 0.5 + +/** + * Resolve a wall opening's along-wall position with Figma-style alignment to + * other objects, publishing the matching guide as a side effect. + * + * The probe is the RAW cursor position on the wall (not the 0.5m snap) so + * off-grid anchors are caught; we then keep only the guide on an axis the wall + * runs along and map it to the along-wall coordinate that lands the opening on + * it. Falls back to the half-metre snap when nothing aligns, and clears the + * guide on bypass / no-match. Returns the localX to use (X-clamped to the wall + * given `width`). `bypass` (Alt) disables alignment. + */ +export function resolveWallSlideAlignment(args: { + wallNode: WallNode + rawLocalX: number + width: number + candidates: readonly AlignmentAnchor[] + bypass: boolean +}): number { + const { wallNode, rawLocalX, width, candidates, bypass } = args + const base = snapToHalf(rawLocalX) + if (bypass || candidates.length === 0) { + useAlignmentGuides.getState().clear() + return base + } + + const dx = wallNode.end[0] - wallNode.start[0] + const dz = wallNode.end[1] - wallNode.start[1] + const wallLength = Math.sqrt(dx * dx + dz * dz) + if (wallLength < 1e-6) { + useAlignmentGuides.getState().clear() + return base + } + const cos = dx / wallLength + const sin = dz / wallLength + const clampX = (localX: number) => Math.max(width / 2, Math.min(wallLength - width / 2, localX)) + + const probe = resolveAlignment({ + moving: [ + { + nodeId: '__wall-opening-draft__', + kind: 'corner', + x: wallNode.start[0] + rawLocalX * cos, + z: wallNode.start[1] + rawLocalX * sin, + }, + ], + candidates, + threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M, + }) + + // Keep only a guide on an axis the wall runs along, mapped to the along-wall + // position that satisfies it; pick the nearest such. + let bestLocalX: number | null = null + let bestDelta = Number.POSITIVE_INFINITY + for (const guide of probe.guides) { + const denom = guide.axis === 'x' ? cos : sin + if (Math.abs(denom) < MIN_AXIS_COMPONENT) continue + const origin = guide.axis === 'x' ? wallNode.start[0] : wallNode.start[1] + const targetLocalX = (guide.coord - origin) / denom + const delta = Math.abs(targetLocalX - rawLocalX) + if (delta < bestDelta) { + bestDelta = delta + bestLocalX = targetLocalX + } + } + if (bestLocalX === null) { + useAlignmentGuides.getState().clear() + return base + } + + const clampedX = clampX(bestLocalX) + // Re-resolve from where the opening actually lands (post-clamp) so the + // published guide connects to the opening, not the raw cursor. + const published = resolveAlignment({ + moving: [ + { + nodeId: '__wall-opening-draft__', + kind: 'corner', + x: wallNode.start[0] + clampedX * cos, + z: wallNode.start[1] + clampedX * sin, + }, + ], + candidates, + threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M, + }) + if (published.guides.length === 0) { + useAlignmentGuides.getState().clear() + } else { + useAlignmentGuides.getState().set(published.guides) + } + return clampedX +} diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 07f298f16..e80cc372e 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -1,6 +1,14 @@ 'use client' -import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' +import { + collectAlignmentAnchors, + emitter, + type GridEvent, + type LevelNode, + resolveAlignment, + useAlignmentGuides, + useScene, +} from '@pascal-app/core' import { CursorSphere, EDITOR_LAYER, @@ -26,6 +34,8 @@ import { SlabNode } from './schema' */ const Y_OFFSET = 0.02 +/** Figma-style alignment-snap threshold (meters), matching the move tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 function calculateSnapPoint( lastPoint: [number, number], @@ -80,21 +90,66 @@ export const SlabTool: React.FC = () => { // isn't built with a stale preset's parameters. Unmount-only. useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), []) + // Clear alignment guides on unmount ONLY. The main drawing effect re-runs + // on every cursor move (cursorPosition is in its deps), so clearing guides + // in its cleanup would wipe the guide the instant after each move sets it. + useEffect(() => () => useAlignmentGuides.getState().clear(), []) + useEffect(() => { if (!currentLevelId) return + // Alignment candidates — anchors of every OTHER alignable object. The + // slab's own in-progress vertices are intentionally excluded (no + // self-alignment while drawing). + const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + // Snap the drafted vertex onto another object's nearest real anchor and + // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped + // point: resolving against the grid point would only ever catch anchors + // that happen to sit on a grid line, so off-grid items (furniture, angled + // walls) would never surface a guide. The matched axis locks exactly to the + // candidate's coordinate; the other axis keeps its grid/ortho snap. Alt + // bypasses. + const alignPoint = ( + fallback: [number, number], + raw: [number, number], + bypass: boolean, + ): [number, number] => { + if (bypass || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return fallback + } + const ar = resolveAlignment({ + moving: [{ nodeId: '__slab-draft__', kind: 'corner', x: raw[0], z: raw[1] }], + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (ar.guides.length === 0) { + useAlignmentGuides.getState().clear() + return fallback + } + useAlignmentGuides.getState().set(ar.guides) + let [x, z] = fallback + for (const guide of ar.guides) { + if (guide.axis === 'x') x = guide.coord + else z = guide.coord + } + return [x, z] + } + const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] + const gridX = Math.round(rawPoint[0] * 2) / 2 + const gridZ = Math.round(rawPoint[1] * 2) / 2 const gridPosition: [number, number] = [gridX, gridZ] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const lastPoint = points[points.length - 1] - const displayPoint = + const orthoPoint = shiftPressed.current || !lastPoint ? gridPosition : calculateSnapPoint(lastPoint, gridPosition) + const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true) setSnappedCursorPosition(displayPoint) if ( points.length > 0 && @@ -121,6 +176,7 @@ export const SlabTool: React.FC = () => { const slabId = commitSlabDrawing(currentLevelId, points) setSelection({ selectedIds: [slabId] }) setPoints([]) + useAlignmentGuides.getState().clear() } else { setPoints([...points, clickPoint]) } @@ -132,12 +188,14 @@ export const SlabTool: React.FC = () => { const slabId = commitSlabDrawing(currentLevelId, points) setSelection({ selectedIds: [slabId] }) setPoints([]) + useAlignmentGuides.getState().clear() } } const onCancel = () => { if (points.length > 0) markToolCancelConsumed() setPoints([]) + useAlignmentGuides.getState().clear() } const onKeyDown = (e: KeyboardEvent) => { diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index b143c7774..246a6a75b 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -1,10 +1,13 @@ import { calculateLevelMiters, + collectAlignmentAnchors, emitter, type GridEvent, getWallMiterBoundaryPoints, type LevelNode, type Point2D, + resolveAlignment, + useAlignmentGuides, useScene, type WallMiterData, type WallNode, @@ -45,6 +48,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 */ const WALL_HEIGHT = 2.5 const DRAFT_WALL_THICKNESS = 0.1 +/** Figma-style alignment-snap threshold (meters), matching the move tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 // HUD label heights are measured from the top of the preview bar, so they // track whatever height a seeded preset draws at (`previewHeight`). const DRAFT_LABEL_Y_OFFSET = 0.22 @@ -429,10 +434,34 @@ export const WallTool: React.FC = () => { let gridPosition: WallPlanPoint = [0, 0] let previousWallEnd: [number, number] | null = null + // Alignment candidates — anchors of every alignable object. Refreshed + // after each segment commits (the new wall becomes a candidate too). + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + const refreshAlignmentCandidates = () => { + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + } + + // Align the drafted point onto another object's nearest real anchor and + // publish the guide. Alt bypasses. Returns the (possibly snapped) point. + const alignPoint = (point: WallPlanPoint, bypass: boolean): WallPlanPoint => { + if (bypass || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return point + } + const ar = resolveAlignment({ + moving: [{ nodeId: '__wall-draft__', kind: 'corner', x: point[0], z: point[1] }], + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + useAlignmentGuides.getState().set(ar.guides) + return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point + } + const stopDrafting = () => { buildingState.current = 0 wallPreviewRef.current.visible = false setDraftMeasurement(null) + useAlignmentGuides.getState().clear() } const onGridMove = (event: GridEvent) => { @@ -446,14 +475,11 @@ export const WallTool: React.FC = () => { // walls fall out of grid snap naturally when the start sits on // a grid intersection. const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined - gridPosition = snapWallDraftPoint({ point: localPoint, walls, step }) + const bypassAlign = event.nativeEvent?.altKey === true + gridPosition = alignPoint(snapWallDraftPoint({ point: localPoint, walls, step }), bypassAlign) if (buildingState.current === 1) { - const snappedLocal = snapWallDraftPoint({ - point: localPoint, - walls, - step, - }) + const snappedLocal = gridPosition endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) cursorRef.current.position.copy(endingPoint.current) @@ -499,9 +525,13 @@ export const WallTool: React.FC = () => { const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined + const bypassAlign = event.nativeEvent?.altKey === true if (buildingState.current === 0) { - const snappedStart = snapWallDraftPoint({ point: localClick, walls, step: clickStep }) + const snappedStart = alignPoint( + snapWallDraftPoint({ point: localClick, walls, step: clickStep }), + bypassAlign, + ) gridPosition = snappedStart startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) endingPoint.current.copy(startingPoint.current) @@ -515,11 +545,10 @@ export const WallTool: React.FC = () => { // `onGridMove` writes a real BoxGeometry skips that frame. setDraftMeasurement(null) } else if (buildingState.current === 1) { - const snappedEnd = snapWallDraftPoint({ - point: localClick, - walls, - step: clickStep, - }) + const snappedEnd = alignPoint( + snapWallDraftPoint({ point: localClick, walls, step: clickStep }), + bypassAlign, + ) const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return @@ -530,6 +559,11 @@ export const WallTool: React.FC = () => { ) if (!createdWall) return + // The new segment is now a real node — make it an alignment target + // for the next segment, and drop the just-shown guide. + refreshAlignmentCandidates() + useAlignmentGuides.getState().clear() + const nextStart = createdWall.end startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) endingPoint.current.copy(startingPoint.current) @@ -572,6 +606,7 @@ export const WallTool: React.FC = () => { emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) + useAlignmentGuides.getState().clear() } }, [unit]) diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index bbb0504ec..13b5ed49a 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,9 +1,11 @@ import { type AnyNodeId, + collectAlignmentAnchors, emitter, isCurvedWall, sceneRegistry, spatialGridManager, + useAlignmentGuides, useLiveTransforms, useScene, type WallEvent, @@ -23,6 +25,7 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' const edgeMaterial = new LineBasicNodeMaterial({ @@ -108,8 +111,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false + useAlignmentGuides.getState().clear() } + // Alignment candidates — anchors of every OTHER alignable object (the + // moving window is excluded so it never aligns to itself). Along-wall only; + // the floor-plane guides don't cover sill height. + const alignmentCandidates = collectAlignmentAnchors( + useScene.getState().nodes, + movingWindowNode.id, + ) + const updateCursor = ( worldPosition: [number, number, number], cursorRotationY: number, @@ -136,7 +148,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width: movingWindowNode.width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const localY = snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, @@ -200,7 +218,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width: movingWindowNode.width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const localY = snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, @@ -269,7 +293,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) - const localX = snapToHalf(event.localPosition[0]) + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width: movingWindowNode.width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const localY = snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, @@ -422,6 +452,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } } useLiveTransforms.getState().clear(movingWindowNode.id) + useAlignmentGuides.getState().clear() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index c0b7b5285..7f1c67ec9 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -1,9 +1,11 @@ import { type AnyNodeId, + collectAlignmentAnchors, emitter, isCurvedWall, sceneRegistry, spatialGridManager, + useAlignmentGuides, useScene, type WallEvent, WindowNode, @@ -21,6 +23,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' // Shared edge material — reuse across renders, just toggle color @@ -70,8 +73,14 @@ const WindowTool: React.FC = () => { const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false + useAlignmentGuides.getState().clear() } + // Alignment candidates — anchors of every alignable object; refreshed + // after each placement. A window aligns by the plan position of its centre + // (along-wall only; the floor-plane guides don't cover sill height). + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + const updateCursor = ( worldPosition: [number, number, number], cursorRotationY: number, @@ -103,11 +112,16 @@ const WindowTool: React.FC = () => { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) - const localY = snapToHalf(event.localPosition[1]) - const width = 1.5 const height = 1.5 + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) + const localY = snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) @@ -153,11 +167,16 @@ const WindowTool: React.FC = () => { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) - const localY = snapToHalf(event.localPosition[1]) - const width = draftRef.current?.width ?? 1.5 const height = draftRef.current?.height ?? 1.5 + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) + const localY = snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) @@ -221,7 +240,13 @@ const WindowTool: React.FC = () => { const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) - const localX = snapToHalf(event.localPosition[0]) + const localX = resolveWallSlideAlignment({ + wallNode: event.node, + rawLocalX: event.localPosition[0], + width: draftRef.current.width, + candidates: alignmentCandidates, + bypass: event.nativeEvent?.altKey === true, + }) const localY = snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, @@ -287,6 +312,8 @@ const WindowTool: React.FC = () => { useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() triggerSFX('sfx:item-place') + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + useAlignmentGuides.getState().clear() event.stopPropagation() } @@ -310,6 +337,7 @@ const WindowTool: React.FC = () => { return () => { destroyDraft() hideCursor() + useAlignmentGuides.getState().clear() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) From e5db58946c962b6b00020f5cb0ccbea580896437 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 01:38:54 +0530 Subject: [PATCH 09/17] fix(editor): axis-stable resize-arrow drag plane + slimmer gizmo handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the linear resize-arrow's drag plane so it always contains the handle's axis (view direction minus its along-axis component) instead of a plane that merely faces the camera. The old camera-facing normal collapsed when the axis pointed toward the viewer — screen motion barely changed the axis component, so the resize crawled or stopped tracking the cursor. Also slim the extruded arrow/handle geometry (shared by the node arrows, wall side handles, and polygon editor) for a lighter gizmo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/editor/node-arrow-handles.tsx | 30 ++++++++++++------- .../editor/wall-move-side-handles.tsx | 10 +++---- .../tools/shared/polygon-editor.tsx | 10 +++---- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 7f58b3dc2..8d2a9bc36 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -200,16 +200,16 @@ function createArrowHandleGeometry() { shape.lineTo(-0.04, -0.12) shape.lineTo(0.22, 0) const geometry = new ExtrudeGeometry(shape, { - depth: 0.08, + depth: 0.045, bevelEnabled: true, - bevelThickness: 0.035, - bevelSize: 0.03, + bevelThickness: 0.018, + bevelSize: 0.02, bevelOffset: 0, - bevelSegments: 10, + bevelSegments: 8, curveSegments: 16, steps: 1, }) - geometry.translate(0, 0, -0.04) + geometry.translate(0, 0, -0.0225) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() geometry.computeBoundingSphere() @@ -750,9 +750,6 @@ function LinearArrow({ const activate = (event: ThreeEvent) => { event.stopPropagation() - // Raycast plane at the handle's world position, perpendicular to the - // camera's projected horizontal direction. For axis='y' we need the - // plane to be vertical too — projection.y maps directly. rideObject.updateMatrixWorld() // Freeze the ride frame at drag-start. Some kinds park their mesh // position on the field being dragged (ceiling: mesh.position.y = @@ -762,8 +759,21 @@ function LinearArrow({ // pose for the duration of the drag. const initialFrameInverse = new Matrix4().copy(rideObject.matrixWorld).invert() const worldOrigin = new Vector3(...position).applyMatrix4(rideObject.matrixWorld) - const planeNormal = new Vector3().subVectors(camera.position, worldOrigin).setY(0) - if (planeNormal.lengthSq() === 0) return + // Drag plane MUST contain the handle's axis. The resize value is read off + // the hit point's component along that axis, so a plane that merely faces + // the camera (the old `setY(0)` normal) collapses when the axis points + // toward the viewer: the axis lies near the plane normal, screen motion + // barely changes the axis component, and the resize crawls / stops + // following the cursor. Build the world-space axis from the frozen ride + // frame, then take the view direction with its along-axis part removed — + // that plane contains the axis yet faces the camera as squarely as + // possible for a stable intersection. (For axis='y' this reduces to the + // old vertical plane, since the view's vertical component is dropped.) + const axisIndex = descriptor.axis === 'x' ? 0 : descriptor.axis === 'y' ? 1 : 2 + const worldAxis = new Vector3().setFromMatrixColumn(rideObject.matrixWorld, axisIndex).normalize() + const viewDir = new Vector3().subVectors(worldOrigin, camera.position) + const planeNormal = viewDir.addScaledVector(worldAxis, -viewDir.dot(worldAxis)) + if (planeNormal.lengthSq() < 1e-10) return planeNormal.normalize() const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin) diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 3274dde33..0cff52286 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -86,12 +86,12 @@ function createArrowHandleGeometry() { shape.lineTo(0.22, 0) const geometry = new ExtrudeGeometry(shape, { - depth: 0.08, + depth: 0.045, bevelEnabled: true, - bevelThickness: 0.035, - bevelSize: 0.03, + bevelThickness: 0.018, + bevelSize: 0.02, bevelOffset: 0, - bevelSegments: 10, + bevelSegments: 8, curveSegments: 16, steps: 1, }) @@ -99,7 +99,7 @@ function createArrowHandleGeometry() { // Centre the extruded plate around y=0 and re-orient it so the depth // axis points up: the chevron lies flat in the XZ plane, tip along +X, // wings spread across ±Z. - geometry.translate(0, 0, -0.04) + geometry.translate(0, 0, -0.0225) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() geometry.computeBoundingSphere() diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 4f14283ea..676514799 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -49,16 +49,16 @@ function createEdgeArrowGeometry() { shape.lineTo(-0.04, -0.12) shape.lineTo(0.22, 0) const geometry = new ExtrudeGeometry(shape, { - depth: 0.08, + depth: 0.045, bevelEnabled: true, - bevelThickness: 0.035, - bevelSize: 0.03, + bevelThickness: 0.018, + bevelSize: 0.02, bevelOffset: 0, - bevelSegments: 10, + bevelSegments: 8, curveSegments: 16, steps: 1, }) - geometry.translate(0, 0, -0.04) + geometry.translate(0, 0, -0.0225) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() geometry.computeBoundingSphere() From 59a5d3853f8720f10deb19373b6cc1afb12d5fec Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 01:38:54 +0530 Subject: [PATCH 10/17] fix(editor): fill-block wall opening highlight + selected frameless openings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draw the selected-wall opening highlight as a translucent block filling the cutout volume (front-side culled) instead of a single vertical pane, so it reads as an occupied slot from any angle — including a top-down floorplan view where an edge-on pane was invisible. Also highlight a directly-selected frameless opening (a `door` with openingKind `'opening'`), which otherwise renders no geometry of its own, and reflect live drag overrides via `useLiveNodeOverrides`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/wall-opening-highlights.tsx | 89 ++++++++++++------- 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/packages/editor/src/components/editor/wall-opening-highlights.tsx b/packages/editor/src/components/editor/wall-opening-highlights.tsx index e93aca5cf..6718acb6d 100644 --- a/packages/editor/src/components/editor/wall-opening-highlights.tsx +++ b/packages/editor/src/components/editor/wall-opening-highlights.tsx @@ -1,18 +1,10 @@ 'use client' -import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' +import { type AnyNodeId, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { createPortal, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' -import { - BoxGeometry, - type BufferGeometry, - DoubleSide, - EdgesGeometry, - type Group, - PlaneGeometry, - Vector3, -} from 'three' +import { BoxGeometry, type BufferGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' @@ -36,14 +28,15 @@ const outlineMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) -// Translucent pane that fills the opening so it reads as a highlighted -// region. Sits in the opening's plane (XY, facing the wall normal) and is -// double-sided so it shows from either side of the wall. +// Translucent block that fills the opening volume so the cutout reads as an +// occupied slot from every angle — the front face shows it head-on, the top +// face shows it from a top-down floorplan view (a single vertical pane would +// be edge-on, hence invisible, when looking straight down). Front-side culling +// keeps the translucency even instead of doubling up on overlapping back faces. const fillMaterial = new MeshBasicNodeMaterial({ color: ACCENT, transparent: true, - opacity: 0.22, - side: DoubleSide, + opacity: 0.5, depthTest: false, depthWrite: false, }) @@ -56,7 +49,7 @@ function makeOutlineGeometry(width: number, height: number, depth: number): Buff } /** - * When a wall is selected, draws a translucent indigo highlight (filled pane + * When a wall is selected, draws a translucent indigo highlight (filled block * + outline) over each door / window it hosts. Openings whose `openingKind` * is `'opening'` have no visible geometry, so without this affordance the * user can't tell an editable cutout lives there — the fill marks it (and @@ -74,43 +67,73 @@ export function WallOpeningHighlights() { return createPortal( <> {selectedIds.map((id) => ( - + ))} , scene, ) } -function WallOpenings({ wallId }: { wallId: string }) { - const wall = useScene((state) => state.nodes[wallId as AnyNodeId]) - - if (!wall || wall.type !== 'wall') return null +// Resolves a selected node into the opening highlight(s) to draw: +// - a selected wall → a hint over each door / window it hosts ("editable +// child here"). +// - a directly-selected frameless opening (a `door` whose `openingKind` is +// `'opening'`) → a fill over its own cutout, so the selection reads as +// occupied even though the opening renders no geometry of its own. +function SelectionOpeningHighlights({ selectedId }: { selectedId: string }) { + const node = useScene((state) => state.nodes[selectedId as AnyNodeId]) + + if (node?.type === 'wall') { + const depth = node.thickness ?? 0.1 + return ( + <> + {(node.children ?? []).map((childId) => ( + + ))} + + ) + } + + if (node?.type === 'door' && node.openingKind === 'opening') { + return + } + + return null +} - const depth = wall.thickness ?? 0.1 - return ( - <> - {(wall.children ?? []).map((childId) => ( - - ))} - - ) +// A frameless opening selected on its own. Pulls the cutout depth from its +// host wall's thickness so the fill block matches the wall it sits in. +function SelectedOpeningHighlight({ + openingId, + parentId, +}: { + openingId: string + parentId: string | null +}) { + const parent = useScene((state) => (parentId ? state.nodes[parentId as AnyNodeId] : undefined)) + const depth = parent?.type === 'wall' ? (parent.thickness ?? 0.1) : 0.1 + return } function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) { const node = useScene((state) => state.nodes[openingId as AnyNodeId]) + // Resize arrows publish width/height to the live-override store during the + // drag and only commit to the scene node on pointer-up, so read the + // override-merged dimensions to keep the highlight box tracking the resize. + const override = useLiveNodeOverrides((s) => s.overrides.get(openingId)) const groupRef = useRef(null) const isOpening = node?.type === 'door' || node?.type === 'window' - const width = isOpening ? node.width : 0 - const height = isOpening ? node.height : 0 + const width = isOpening ? ((override?.width as number | undefined) ?? node.width) : 0 + const height = isOpening ? ((override?.height as number | undefined) ?? node.height) : 0 const outlineGeometry = useMemo( () => (isOpening ? makeOutlineGeometry(width, height, depth) : null), [isOpening, width, height, depth], ) const fillGeometry = useMemo( - () => (isOpening ? new PlaneGeometry(width, height) : null), - [isOpening, width, height], + () => (isOpening ? new BoxGeometry(width, height, depth) : null), + [isOpening, width, height, depth], ) useEffect(() => () => outlineGeometry?.dispose(), [outlineGeometry]) useEffect(() => () => fillGeometry?.dispose(), [fillGeometry]) From d26ccaa817f3fe2382da4905dc4d6abc245a0246 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 01:38:54 +0530 Subject: [PATCH 11/17] fix(viewer): double-side slab hole side-walls Build the slab in 3D rather than via ExtrudeGeometry so each hole-wall quad is emitted twice with opposite winding. The slab material is forced to FrontSide (DoubleSide poisons the MRT scene pass), under which ExtrudeGeometry's single-sided hole walls get back-face culled and you see straight through the cut. The doubled quads keep the cut's inner thickness visible from any angle without z-fighting. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../viewer/src/systems/slab/slab-system.tsx | 95 ++++++++++++++++--- 1 file changed, 80 insertions(+), 15 deletions(-) diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 403f0438e..0e7fc0ab9 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -83,6 +83,15 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { /** * Standard slab: flat extrusion upward from Y=0 by elevation thickness. + * + * Built directly in 3D (Y-up) rather than via ExtrudeGeometry so the hole side + * walls can be emitted double-sided. The slab material is forced to FrontSide + * (DoubleSide on the floor-role NodeMaterial poisons the MRT scene pass — see + * nodes/slab/geometry.ts), and ExtrudeGeometry's hole walls are single-sided, + * so their interior faces get back-face culled and you see straight through the + * cut. Emitting each hole-wall quad twice with opposite winding makes the inner + * thickness visible from any angle: the two coincident triangles never z-fight + * because exactly one faces the camera under FrontSide culling. */ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { const polygon = getRenderableSlabPolygon(slabNode) @@ -91,23 +100,79 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry if (polygon.length < 3) return new THREE.BufferGeometry() - const shape = new THREE.Shape() - shape.moveTo(polygon[0]![0], -polygon[0]![1]) - for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1]) - shape.closePath() - - for (const holePolygon of holePolygons) { - if (holePolygon.length < 3) continue - const holePath = new THREE.Path() - holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1]) - for (let i = 1; i < holePolygon.length; i++) - holePath.lineTo(holePolygon[i]![0], -holePolygon[i]![1]) - holePath.closePath() - shape.holes.push(holePath) + const positions: number[] = [] + const uvs: number[] = [] + const indices: number[] = [] + + const contour2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!)) + const holes2d = holePolygons + .filter((h) => h.length >= 3) + .map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) + + // --- Top & bottom caps --- + // capPoints order (contour then holes) matches triangulateShape's index space. + // UVs reproduce ExtrudeGeometry's WorldUVGenerator mapping (shape-space x,-z) + // so textured slabs keep the same floor projection. + const capPoints = [...contour2d, ...holes2d.flat()] + const topBase = positions.length / 3 + for (const p of capPoints) { + positions.push(p.x, elevation, p.y) + uvs.push(p.x, -p.y) + } + const bottomBase = positions.length / 3 + for (const p of capPoints) { + positions.push(p.x, 0, p.y) + uvs.push(p.x, -p.y) + } + + const capTris = THREE.ShapeUtils.triangulateShape(contour2d, holes2d) + for (const tri of capTris) { + const [a, b, c] = [tri[0]!, tri[1]!, tri[2]!] + // Reversed winding → +Y normal on top; standard winding → -Y on bottom. + indices.push(topBase + a, topBase + c, topBase + b) + indices.push(bottomBase + a, bottomBase + b, bottomBase + c) + } + + // --- Side walls --- + // Each segment gets its own 4 verts so computeVertexNormals doesn't average + // across faces. Outer walls are single-sided with outward normals; hole walls + // emit a second flipped quad (own verts) so they read as double-sided. + const addWall = (a: THREE.Vector2, b: THREE.Vector2, flipped: boolean) => { + const base = positions.length / 3 + const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001) + positions.push(a.x, 0, a.y) + uvs.push(0, 0) + positions.push(b.x, 0, b.y) + uvs.push(len, 0) + positions.push(b.x, elevation, b.y) + uvs.push(len, elevation) + positions.push(a.x, elevation, a.y) + uvs.push(0, elevation) + // Standard winding on a CCW polygon gives inward-facing normals (see pool + // path), so the unflipped quad faces outward; flipped is its back face. + if (!flipped) { + indices.push(base, base + 2, base + 1, base, base + 3, base + 2) + } else { + indices.push(base, base + 1, base + 2, base, base + 2, base + 3) + } + } + + for (let i = 0; i < contour2d.length; i++) { + addWall(contour2d[i]!, contour2d[(i + 1) % contour2d.length]!, false) + } + for (const hole of holes2d) { + for (let i = 0; i < hole.length; i++) { + const a = hole[i]! + const b = hole[(i + 1) % hole.length]! + addWall(a, b, false) + addWall(a, b, true) + } } - const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false }) - geometry.rotateX(-Math.PI / 2) + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + geometry.setIndex(indices) geometry.computeVertexNormals() return geometry } From 8a64c10a9ef7613e7f61e4d12de3bc8ee7b892f1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 01:38:54 +0530 Subject: [PATCH 12/17] fix(editor): box-select building-scoped nodes like elevators Building-scoped selectable nodes (e.g. elevators) are children of the building, not the active level, so the level walk never reached them. Also walk the level's building children and box-test any registry- selectable kind by its rendered bounds, matching the column/stair/shelf path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tools/select/box-select-tool.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 20f7e5047..43a647b58 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -10,6 +10,7 @@ import { type ItemNode, isRegistrySelectable, type LevelNode, + resolveBuildingForLevel, type SlabNode, sceneRegistry, useScene, @@ -290,6 +291,25 @@ function collectNodeIdsInBounds(bounds: Bounds | null): string[] { } } } + + // Building-scoped selectable nodes (e.g. elevator) are siblings of the + // level — children of the building, not the level — so the loop above + // never reaches them. Walk the active level's building children and + // box-test any registry-selectable kind by its rendered bounds, the same + // path column/stair/shelf use. + const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes) + const buildingNode = buildingId ? nodes[buildingId] : undefined + const buildingChildren = + buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children) + ? (buildingNode.children as AnyNodeId[]) + : [] + for (const childId of buildingChildren) { + const node = nodes[childId] + if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue + if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) { + result.push(node.id) + } + } } return result From 1f9c0d44b0b0887c9af006d9d467b46ebf66fec7 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 11:11:06 +0530 Subject: [PATCH 13/17] feat(editor): shelf placement alignment + column placement ghost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shelf placement now snaps to Figma-style alignment guides by its footprint edges (layered on grid snap, Alt bypasses), matching the existing 3D move tool. Guides refresh after each drop and clear on teardown. Column placement migrates to the registry `def.tool` path so it can render a translucent column ghost at the cursor (like the shelf build tool) instead of a bare cursor sphere — the editor package can't import the column geometry, so the tool now lives in packages/nodes: - extract `ColumnBody` from the renderer and add a `ColumnPreview` (cloned translucent material, raycast disabled, origin-positioned) - new `column/tool.tsx` registry placement tool with the same footprint-edge alignment as shelf / column move - wire `def.tool` + tool hints; drop the now-unreachable legacy editor-side `ColumnTool` and its dead file Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/tools/column/column-tool.tsx | 147 ------------- .../src/components/tools/tool-manager.tsx | 8 +- packages/nodes/src/column/definition.ts | 10 + packages/nodes/src/column/renderer.tsx | 202 +++++++++++------- packages/nodes/src/column/tool.tsx | 149 +++++++++++++ packages/nodes/src/shelf/tool.tsx | 58 ++++- 6 files changed, 339 insertions(+), 235 deletions(-) delete mode 100644 packages/editor/src/components/tools/column/column-tool.tsx create mode 100644 packages/nodes/src/column/tool.tsx diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx deleted file mode 100644 index 98866f109..000000000 --- a/packages/editor/src/components/tools/column/column-tool.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import '../../../three-types' - -import { - COLUMN_PRESETS, - ColumnNode, - type ColumnNode as ColumnNodeType, - type ColumnPresetId, - collectAlignmentAnchors, - emitter, - type GridEvent, - type LevelNode, - resolveAlignment, - useAlignmentGuides, - useScene, -} from '@pascal-app/core' -import { useEffect, useRef, useState } from 'react' -import type { Group } from 'three' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { CursorSphere } from '../shared/cursor-sphere' - -/** Figma-style alignment-snap threshold (meters), matching the move tools. */ -const ALIGNMENT_THRESHOLD_M = 0.08 - -const COLUMN_ICON = ( - // eslint-disable-next-line @next/next/no-img-element - Column -) - -const roundToHalf = (value: number) => Math.round(value * 2) / 2 -const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId - -function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) { - const { label, ...preset } = COLUMN_PRESETS[presetId] - return ColumnNode.parse({ - name: label, - position, - rotation: 0, - ...preset, - }) -} - -type ColumnToolProps = { - currentLevelId: LevelNode['id'] | null - onPlaced?: (nodeId: ColumnNodeType['id']) => void -} - -export const ColumnTool: React.FC = ({ currentLevelId, onPlaced }) => { - const [, setCursorPosition] = useState<[number, number, number] | null>(null) - const cursorRef = useRef(null) - - useEffect(() => { - if (!currentLevelId) return - - // Alignment candidates — anchors of every alignable object; refreshed - // after each placement so a newly-placed column is a target too. - let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') - // Snap the column origin onto another object's nearest real anchor and - // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped - // point: resolving against the grid point would only ever catch anchors - // that happen to sit on a grid line, so off-grid items (furniture, angled - // walls) would never surface a guide. The matched axis locks exactly to the - // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses. - const alignPoint = ( - gridX: number, - gridZ: number, - rawX: number, - rawZ: number, - bypass: boolean, - ): [number, number] => { - if (bypass || alignmentCandidates.length === 0) { - useAlignmentGuides.getState().clear() - return [gridX, gridZ] - } - const ar = resolveAlignment({ - moving: [{ nodeId: '__column-draft__', kind: 'corner', x: rawX, z: rawZ }], - candidates: alignmentCandidates, - threshold: ALIGNMENT_THRESHOLD_M, - }) - if (ar.guides.length === 0) { - useAlignmentGuides.getState().clear() - return [gridX, gridZ] - } - useAlignmentGuides.getState().set(ar.guides) - let x = gridX - let z = gridZ - for (const guide of ar.guides) { - if (guide.axis === 'x') x = guide.coord - else z = guide.coord - } - return [x, z] - } - - const onGridMove = (event: GridEvent) => { - const [ax, az] = alignPoint( - roundToHalf(event.localPosition[0]), - roundToHalf(event.localPosition[2]), - event.localPosition[0], - event.localPosition[2], - event.nativeEvent?.altKey === true, - ) - const nextPosition: [number, number, number] = [ax, 0, az] - setCursorPosition(nextPosition) - cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2]) - } - - const onGridClick = (event: GridEvent) => { - const [ax, az] = alignPoint( - roundToHalf(event.localPosition[0]), - roundToHalf(event.localPosition[2]), - event.localPosition[0], - event.localPosition[2], - event.nativeEvent?.altKey === true, - ) - const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [ax, 0, az]) - useScene.getState().createNode(column, currentLevelId) - onPlaced?.(column.id) - sfxEmitter.emit('sfx:structure-build') - alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') - useAlignmentGuides.getState().clear() - } - - emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) - - return () => { - emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) - useAlignmentGuides.getState().clear() - } - }, [currentLevelId, onPlaced]) - - if (!currentLevelId) return null - - return ( - - ) -} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 8d515bd65..b8e88642b 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -10,7 +10,6 @@ import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' -import { ColumnTool } from './column/column-tool' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' @@ -253,9 +252,6 @@ export const ToolManager: React.FC = () => { )} - {!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && ( - - )} {!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && ( { onPlaced={handlePlacedElevatorSelected} /> )} - {!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? ( - - ) : null} + {!movingNode && BuildToolComponent && tool !== 'elevator' ? : null} {/* Figma-style alignment guides published by the move / placement tools above. Lives inside the building-local group so the building-local guide coords render at the right world position. */} diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index 09a4ad10d..51bb2e33f 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -327,6 +327,16 @@ export const columnDefinition: NodeDefinition = { affordanceTools: { move: () => import('./move-tool'), }, + // Registry-driven placement tool — renders a translucent `ColumnPreview` + // ghost at the cursor (mirroring the shelf build tool) instead of the + // bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s + // registry-first path mounts this and skips the legacy ``. + tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Place column' }, + { key: 'Alt', label: 'No snap' }, + { key: 'Esc', label: 'Cancel' }, + ], floorplan: buildColumnFloorplan, // 2D drag affordances — `column-resize` handles every dimension arrow // the floor-plan builder emits per cross-section / support style (the diff --git a/packages/nodes/src/column/renderer.tsx b/packages/nodes/src/column/renderer.tsx index 788c907fc..05c9e5ab4 100644 --- a/packages/nodes/src/column/renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -20,7 +20,7 @@ import { useNodeEvents, useViewer, } from '@pascal-app/viewer' -import { createContext, useContext, useMemo, useRef } from 'react' +import { createContext, useContext, useEffect, useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three' const ColumnMaterialContext = createContext(baseMaterial()) @@ -2076,6 +2076,130 @@ function Capital({ node, y, height }: { node: ColumnNode; y: number; height: num ) } +/** + * The column's geometry tree — either a fabricated support frame or the + * classical base / shaft / capital stack. Extracted from `ColumnRenderer` + * so the translucent placement ghost (`ColumnPreview`) renders the exact + * same shape without the registry registration, pointer handlers, or + * live-transform wiring the real renderer layers on. Material and edge + * softness arrive through context, so each caller controls appearance by + * wrapping this in its own providers. + */ +function ColumnBody({ node }: { node: ColumnNode }) { + const shaftLayout = useMemo(() => { + const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4) + const capitalHeight = + node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4) + const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight) + return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight } + }, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height]) + + return node.supportStyle === 'a-frame' ? ( + + ) : node.supportStyle === 'y-frame' ? ( + + ) : node.supportStyle === 'v-frame' ? ( + + ) : node.supportStyle === 'x-brace' ? ( + + ) : node.supportStyle === 'k-brace' ? ( + + ) : node.supportStyle === 'single-strut' ? ( + + ) : node.supportStyle === 'tripod' ? ( + + ) : node.supportStyle === 'trestle' ? ( + + ) : node.supportStyle === 'portal-frame' ? ( + + ) : node.supportStyle === 'box-frame' ? ( + + ) : ( + <> + + + + + + + + + + + + + ) +} + +/** + * Translucent, non-interactive ghost of a column — the placement tool's + * cursor preview, mirroring `ShelfPreview`. Builds the same geometry tree + * as the real renderer via `` but: + * - clones the material and makes it transparent (cloning is required: + * `createColumnMaterial` can hand back a shared/cached instance, and + * mutating it would turn every committed column see-through); + * - disables raycast on every mesh so the ghost doesn't intercept the + * placement cursor ray (which would stall `grid:move`); + * - renders at the local origin so the caller's cursor group positions it. + */ +export const ColumnPreview = ({ node }: { node: ColumnNode }) => { + const shading = useViewer((state) => state.shading) + const textures = useViewer((state) => state.textures) + const colorPreset = useViewer((state) => state.colorPreset) + const groupRef = useRef(null) + + const material = useMemo(() => { + const ghost = createColumnMaterial({ + material: node.material, + materialPreset: node.materialPreset, + shading, + textures, + colorPreset, + }).clone() + ghost.transparent = true + ghost.opacity = 0.5 + ghost.depthWrite = false + return ghost + }, [shading, textures, colorPreset, node.material, node.materialPreset]) + + useEffect(() => () => material.dispose(), [material]) + + // Strip pointer events off the freshly-built meshes every render — the + // geometry tree rebuilds when the ghost's dimensions change, so a one-shot + // effect wouldn't cover later meshes. + useEffect(() => { + groupRef.current?.traverse((obj) => { + ;(obj as unknown as { raycast: () => void }).raycast = () => {} + }) + }) + + return ( + + + + + + + + ) +} + export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { const ref = useRef(null!) // Merge any live drag override so width / depth / radius / height @@ -2115,14 +2239,6 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { useRegistry(node.id, node.type, ref) - const shaftLayout = useMemo(() => { - const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4) - const capitalHeight = - node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4) - const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight) - return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight } - }, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height]) - return ( @@ -2133,73 +2249,7 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { visible={node.visible} {...handlers} > - {node.supportStyle === 'a-frame' ? ( - - ) : node.supportStyle === 'y-frame' ? ( - - ) : node.supportStyle === 'v-frame' ? ( - - ) : node.supportStyle === 'x-brace' ? ( - - ) : node.supportStyle === 'k-brace' ? ( - - ) : node.supportStyle === 'single-strut' ? ( - - ) : node.supportStyle === 'tripod' ? ( - - ) : node.supportStyle === 'trestle' ? ( - - ) : node.supportStyle === 'portal-frame' ? ( - - ) : node.supportStyle === 'box-frame' ? ( - - ) : ( - <> - - - - - - - - - - - - - )} + diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx new file mode 100644 index 000000000..8d5a9fc34 --- /dev/null +++ b/packages/nodes/src/column/tool.tsx @@ -0,0 +1,149 @@ +'use client' + +import { + COLUMN_PRESETS, + ColumnNode, + type ColumnPresetId, + collectAlignmentAnchors, + emitter, + type GridEvent, + movingFootprintAnchors, + resolveAlignment, + snapPointToGrid, + useAlignmentGuides, + useScene, +} from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import type { Group } from 'three' +import { ColumnPreview } from './renderer' + +const GRID_STEP = 0.5 + +/** Figma-style alignment-snap threshold (meters), matching the move tools and + * the shelf placement tool. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + +const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId + +function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) { + const { label, ...preset } = COLUMN_PRESETS[presetId] + return ColumnNode.parse({ + name: label, + position, + rotation: 0, + ...preset, + }) +} + +/** + * Registry-driven column placement tool. Mirrors the shelf build tool: + * a translucent `ColumnPreview` ghost follows the cursor (the piece the + * legacy editor-side `ColumnTool` lacked — it only showed a sphere), grid + * snap is layered with Figma-style alignment, and a `grid:click` commits. + * + * Lives in `packages/nodes` (not the editor) specifically so it can import + * the column geometry for the ghost — the editor package can't depend on + * `nodes`. Wired via `def.tool`, so `ToolManager`'s registry-first path + * mounts it and the legacy `` branch no longer fires. + */ +const ColumnTool = () => { + const activeLevelId = useViewer((state) => state.selection.levelId) + const cursorRef = useRef(null) + const previousSnapRef = useRef<[number, number] | null>(null) + + // Default-preset column for the placement ghost — matches exactly what the + // commit creates (`basicPillar`), so the preview is faithful. + const previewNode = useMemo(() => createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [0, 0, 0]), []) + + useEffect(() => { + if (!activeLevelId) return + previousSnapRef.current = null + + // Alignment candidates — anchors of every other alignable object, gathered + // here and refreshed after each placement so a just-placed column becomes a + // target for the next one. `previewNode.id` never collides with a scene + // node, so nothing real is excluded. + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + + const onGridMove = (event: GridEvent) => { + const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) + + // Figma-style alignment snap layered on top of grid snap: when the + // preview column's footprint edge lines up (on X or Z) with another + // object's edge, snap there and publish a guide. Alt bypasses. + let ax = sx + let az = sz + const bypass = event.nativeEvent?.altKey === true + if (!bypass && alignmentCandidates.length > 0) { + const result = resolveAlignment({ + moving: movingFootprintAnchors(previewNode, sx, sz, 0), + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + ax += result.snap.dx + az += result.snap.dz + } + useAlignmentGuides.getState().set(result.guides) + } else { + useAlignmentGuides.getState().clear() + } + + cursorRef.current?.position.set(ax, event.localPosition[1], az) + + const prev = previousSnapRef.current + if (!prev || prev[0] !== ax || prev[1] !== az) { + triggerSFX('sfx:grid-snap') + previousSnapRef.current = [ax, az] + } + } + + const onGridClick = (event: GridEvent) => { + const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) + let ax = sx + let az = sz + const bypass = event.nativeEvent?.altKey === true + if (!bypass && alignmentCandidates.length > 0) { + const result = resolveAlignment({ + moving: movingFootprintAnchors(previewNode, sx, sz, 0), + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + ax += result.snap.dx + az += result.snap.dz + } + } + + const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [ax, 0, az]) + useScene.getState().createNode(column, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [column.id] }) + triggerSFX('sfx:structure-build') + // The placed column is now a valid alignment target for the next one; + // refresh the candidate pool and drop the guide from this drop. + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + useAlignmentGuides.getState().clear() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + useAlignmentGuides.getState().clear() + } + }, [activeLevelId, previewNode]) + + if (!activeLevelId) return null + + return ( + + + + ) +} + +export default ColumnTool diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index af0ea78a7..dd19c3dc7 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -2,13 +2,17 @@ import { type AnyNode, + collectAlignmentAnchors, type EventSuffix, emitter, type GridEvent, + movingFootprintAnchors, type NodeEvent, + resolveAlignment, ShelfNode, sceneRegistry, snapPointToGrid, + useAlignmentGuides, useScene, } from '@pascal-app/core' import { triggerSFX } from '@pascal-app/editor' @@ -21,6 +25,11 @@ import ShelfPreview from './preview' const worldVector = new Vector3() const GRID_STEP = 0.5 +/** Figma-style alignment-snap threshold (meters), matching the move tools and + * the 2D floor-plan overlay. 8 cm gives a magnetic pull layered on top of the + * grid snap without fighting it. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + /** * Click-trigger kinds: when the user clicks ANY of these during shelf * placement, we commit at the latest cursor position. R3F's pointer @@ -107,15 +116,47 @@ const ShelfTool = () => { */ const lastCursorRef: { current: [number, number, number] | null } = { current: null } + // Alignment candidates — anchors of every OTHER alignable object (items, + // walls, fences, slabs, ceilings, columns, other shelves). Gathered once + // here and refreshed after each placement so a just-placed shelf becomes a + // target for the next one. `previewNode.id` never collides with a scene + // node, so nothing real is excluded. + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + const onGridMove = (event: GridEvent) => { const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) - cursorRef.current?.position.set(sx, event.localPosition[1], sz) - lastCursorRef.current = [sx, event.localPosition[1], sz] + + // Figma-style alignment snap layered on top of grid snap: when the + // preview shelf's footprint edge lines up (on X or Z) with another + // object's edge, snap there and publish a guide. The probe uses the + // shelf's footprint corners at the proposed grid position so it aligns + // by its edges, not its centre — matching `MoveRegistryNodeTool`. Alt + // bypasses. + let ax = sx + let az = sz + const bypass = event.nativeEvent?.altKey === true + if (!bypass && alignmentCandidates.length > 0) { + const result = resolveAlignment({ + moving: movingFootprintAnchors(previewNode, sx, sz, 0), + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + ax += result.snap.dx + az += result.snap.dz + } + useAlignmentGuides.getState().set(result.guides) + } else { + useAlignmentGuides.getState().clear() + } + + cursorRef.current?.position.set(ax, event.localPosition[1], az) + lastCursorRef.current = [ax, event.localPosition[1], az] const prev = previousSnapRef.current - if (!prev || prev[0] !== sx || prev[1] !== sz) { + if (!prev || prev[0] !== ax || prev[1] !== az) { triggerSFX('sfx:grid-snap') - previousSnapRef.current = [sx, sz] + previousSnapRef.current = [ax, az] } } @@ -134,6 +175,10 @@ const ShelfTool = () => { useScene.getState().createNode(shelf, activeLevelId) useViewer.getState().setSelection({ selectedIds: [shelf.id] }) triggerSFX('sfx:structure-build') + // The placed shelf is now a valid alignment target for the next one; + // refresh the candidate pool and drop the guide from this drop. + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + useAlignmentGuides.getState().clear() const native = (event as { nativeEvent?: unknown }).nativeEvent if ( @@ -162,8 +207,11 @@ const ShelfTool = () => { const key = `${kind}:click` as ClickKey emitter.off(key, commitAtCursor as never) } + // Drop any alignment guide left over when the tool deactivates (kind + // switch, Esc, unmount) so it doesn't linger over the canvas. + useAlignmentGuides.getState().clear() } - }, [activeLevelId]) + }, [activeLevelId, previewNode]) if (!activeLevelId) return null From d6d20a5ced354cb2f31478e530e69b8201ae53d6 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 14:28:04 +0530 Subject: [PATCH 14/17] feat(editor): floor-plan alignment, pivot moves & placement ghosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the 3D editor's Figma-style alignment experience to the 2D floor plan across every node kind, fix pivot semantics on move, and add 2D placement ghosts. Alignment - Wall anchors now include ±thickness/2 face corners so columns/items/etc. snap flush to wall faces (fixes pillar↔wall); shared by 2D and 3D. - Shared apply-alignment helper (applyFloorplanAlignment / alignFloorplanDraftPoint, with excludeIds) used by move sessions, structural drafting (wall/fence/slab/zone/ceiling/roof), and wall/fence endpoint drags. - Door/window/wall-item moves get along-wall edge-to-edge snapping. - Generic free-translate move path aligns by edges (corner anchors). Pivot moves (2D) - Polygon kinds (slab/ceiling/zone) move by centroid→cursor via a shared polygon-centroid mover; stair moves by origin→cursor; matching 3D. - Shelf/column move targets write position directly (single source of truth) so the 3D group no longer sticks on commit. Placement ghosts (2D) - usePlacementPreview store + FloorplanPlacementPreviewLayer render a kind's def.floorplan footprint following the cursor; wired for column and elevator. Fixes - Elevator placement no longer deselects the active floor plan (preserve levelId through setSelection's hierarchy guard). - Guides clear on every commit/cancel/unmount path. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/index.ts | 1 + .../src/services/alignment-anchors.test.ts | 21 ++- .../core/src/services/alignment-anchors.ts | 53 ++++++- .../core/src/store/use-placement-preview.ts | 26 ++++ .../floorplan-registry-move-overlay.tsx | 12 +- .../floorplan-placement-preview-layer.tsx | 57 +++++++ .../renderers/floorplan-registry-layer.tsx | 18 ++- .../src/components/editor/floorplan-panel.tsx | 76 +++++++--- .../use-floorplan-background-placement.ts | 66 ++++++-- .../tools/elevator/elevator-tool.tsx | 43 +++++- .../src/components/tools/tool-manager.tsx | 11 +- packages/editor/src/index.tsx | 5 + .../src/lib/floorplan/apply-alignment.ts | 111 ++++++++++++++ packages/editor/src/lib/floorplan/index.ts | 7 + packages/nodes/src/ceiling/floorplan-move.ts | 96 ++---------- packages/nodes/src/column/definition.ts | 13 +- packages/nodes/src/column/floorplan-move.ts | 92 +++++++++++ packages/nodes/src/column/tool.tsx | 12 +- packages/nodes/src/door/floorplan-move.ts | 18 ++- .../nodes/src/fence/floorplan-affordances.ts | 21 ++- packages/nodes/src/item/floorplan-move.ts | 49 ++++-- .../nodes/src/shared/polygon-centroid-move.ts | 143 ++++++++++++++++++ .../nodes/src/shared/wall-attach-target.ts | 89 ++++++++++- packages/nodes/src/shelf/floorplan-move.ts | 115 +++++++------- packages/nodes/src/slab/floorplan-move.ts | 137 ++--------------- packages/nodes/src/stair/floorplan-move.ts | 99 +++++------- .../nodes/src/wall/floorplan-affordances.ts | 16 +- packages/nodes/src/window/floorplan-move.ts | 16 +- packages/nodes/src/zone/definition.ts | 6 + packages/nodes/src/zone/floorplan-move.ts | 15 ++ 30 files changed, 1040 insertions(+), 404 deletions(-) create mode 100644 packages/core/src/store/use-placement-preview.ts create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx create mode 100644 packages/editor/src/lib/floorplan/apply-alignment.ts create mode 100644 packages/nodes/src/column/floorplan-move.ts create mode 100644 packages/nodes/src/shared/polygon-centroid-move.ts create mode 100644 packages/nodes/src/zone/floorplan-move.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c0b8abb0e..dd59d740b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -108,6 +108,7 @@ export { type LiveNodeOverrides, } from './store/use-live-node-overrides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' +export { default as usePlacementPreview } from './store/use-placement-preview' export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { diff --git a/packages/core/src/services/alignment-anchors.test.ts b/packages/core/src/services/alignment-anchors.test.ts index a9e1a6283..d44b15f25 100644 --- a/packages/core/src/services/alignment-anchors.test.ts +++ b/packages/core/src/services/alignment-anchors.test.ts @@ -151,6 +151,25 @@ describe('wallSegmentAnchors', () => { { nodeId: 'w', kind: 'center', x: 2, z: 1 }, ]) }) + + test('adds ±thickness/2 face corners on each endpoint when thickness is given', () => { + // Horizontal wall along +X: perpendicular is ±Z, so faces sit at z = ±0.1. + const anchors = wallSegmentAnchors('w', [0, 0], [4, 0], 0.2) + expect(anchors).toEqual([ + { nodeId: 'w', kind: 'corner', x: 0, z: 0 }, + { nodeId: 'w', kind: 'corner', x: 4, z: 0 }, + { nodeId: 'w', kind: 'center', x: 2, z: 0 }, + { nodeId: 'w', kind: 'corner', x: 0, z: 0.1 }, + { nodeId: 'w', kind: 'corner', x: 0, z: -0.1 }, + { nodeId: 'w', kind: 'corner', x: 4, z: 0.1 }, + { nodeId: 'w', kind: 'corner', x: 4, z: -0.1 }, + ]) + }) + + test('skips face corners for zero/degenerate input', () => { + expect(wallSegmentAnchors('w', [0, 0], [4, 0], 0)).toHaveLength(3) + expect(wallSegmentAnchors('w', [1, 1], [1, 1], 0.2)).toHaveLength(3) + }) }) describe('polygonAnchors', () => { @@ -192,7 +211,7 @@ describe('collectAlignmentAnchors', () => { const ids = anchors.map((a) => a.nodeId) expect(ids).not.toContain('moving') expect(ids.filter((id) => id === 'box')).toHaveLength(4) // corner anchors - expect(ids.filter((id) => id === 'wall')).toHaveLength(3) // endpoints + midpoint + expect(ids.filter((id) => id === 'wall')).toHaveLength(7) // endpoints + midpoint + 4 face corners expect(ids.filter((id) => id === 'slab')).toHaveLength(3) // polygon vertices }) }) diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index 9b3c12402..3fac4eed3 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -14,6 +14,7 @@ import { nodeRegistry } from '../registry' import type { AnyNode } from '../schema/types' +import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' import { type AlignmentAnchor, bboxCornerAnchors } from './alignment' export type FootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number } @@ -119,21 +120,52 @@ export function movingFootprintAnchors( } /** - * Alignment anchors for a wall segment: both endpoints (as `corner`) and - * the chord midpoint (as `center`). Curve offset is ignored — endpoints are - * exact and the midpoint is good enough for v1 alignment. Coordinates are - * the wall's `start` / `end` (building-local XZ meters). + * Alignment anchors for a wall segment: the two centerline endpoints + chord + * midpoint, plus — when `thickness` is known — four **face** corner anchors, + * each endpoint offset by ±thickness/2 perpendicular to the wall axis. + * + * The face anchors are what let a footprint align to a wall's *face* rather + * than its centerline: for an axis-aligned wall the two same-side face + * anchors share a constant X (vertical wall) or Z (horizontal wall) running + * the wall's full length, so the point-to-point resolver snaps a moving + * corner flush to the face anywhere along the wall (the perpendicular + * tie-break connects the guide to the nearer face endpoint). A diagonal wall + * gets only its face/centerline endpoints — point-to-point can't represent a + * sloped face line; that's an accepted v1 limitation. + * + * Curve offset is ignored — endpoints are exact and the chord midpoint is + * good enough for v1. Coordinates are the wall's `start` / `end` + * (building-local XZ meters). */ export function wallSegmentAnchors( id: string, start: readonly [number, number], end: readonly [number, number], + thickness?: number, ): AlignmentAnchor[] { - return [ + const anchors: AlignmentAnchor[] = [ { nodeId: id, kind: 'corner', x: start[0], z: start[1] }, { nodeId: id, kind: 'corner', x: end[0], z: end[1] }, { nodeId: id, kind: 'center', x: (start[0] + end[0]) / 2, z: (start[1] + end[1]) / 2 }, ] + + if (thickness && thickness > 0) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const len = Math.hypot(dx, dz) + if (len > 1e-6) { + // Perpendicular to the wall axis, scaled to half-thickness. + const half = thickness / 2 + const px = (-dz / len) * half + const pz = (dx / len) * half + for (const [bx, bz] of [start, end] as const) { + anchors.push({ nodeId: id, kind: 'corner', x: bx + px, z: bz + pz }) + anchors.push({ nodeId: id, kind: 'corner', x: bx - px, z: bz - pz }) + } + } + } + + return anchors } /** Each vertex of a polygon (slab / ceiling footprint) as a `corner` anchor. */ @@ -152,8 +184,15 @@ export function polygonAnchors( */ export function nodeAlignmentAnchors(node: AnyNode): AlignmentAnchor[] { if (node.type === 'wall' || node.type === 'fence') { - const seg = node as { id: string; start: [number, number]; end: [number, number] } - return wallSegmentAnchors(seg.id, seg.start, seg.end) + const seg = node as { + id: string + start: [number, number] + end: [number, number] + thickness?: number + } + // Wall thickness is schema-optional (falls back to the geometry default); + // fence always carries one. Either way, pass it through so faces align. + return wallSegmentAnchors(seg.id, seg.start, seg.end, seg.thickness ?? DEFAULT_WALL_THICKNESS) } if (node.type === 'slab' || node.type === 'ceiling') { const poly = (node as { polygon?: [number, number][] }).polygon diff --git a/packages/core/src/store/use-placement-preview.ts b/packages/core/src/store/use-placement-preview.ts new file mode 100644 index 000000000..d76083e24 --- /dev/null +++ b/packages/core/src/store/use-placement-preview.ts @@ -0,0 +1,26 @@ +// Ephemeral store for a placement tool's 2D floor-plan ghost. A registry +// placement tool (e.g. column) publishes a fully-positioned, transient +// preview node on each `grid:move`; the floor-plan placement-preview layer +// subscribes and renders the node's `def.floorplan` footprint as a faint +// ghost that follows the cursor. The 3D view already shows a translucent +// mesh preview, so this only feeds the 2D layer. Producers clear on commit, +// cancel, and unmount. + +import { create } from 'zustand' +import type { AnyNode } from '../schema/types' + +type PlacementPreviewState = { + /** Transient preview node, already positioned + rotated at the (snapped, + * aligned) cursor. `null` when no placement is active. */ + node: AnyNode | null + set(node: AnyNode | null): void + clear(): void +} + +const usePlacementPreview = create((set) => ({ + node: null, + set: (node) => set({ node }), + clear: () => set({ node: null }), +})) + +export default usePlacementPreview diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 2fe972fb4..7d01dcb46 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -5,6 +5,7 @@ import { type AnyNode, type AnyNodeId, bboxAnchors, + bboxCornerAnchors, type FloorplanMoveTargetSession, nodeRegistry, pauseSceneHistory, @@ -365,6 +366,11 @@ export function FloorplanRegistryMoveOverlay() { liveTransforms.clear(id) liveOverrides.clear(id) } + // Sessions that publish Figma-style alignment guides during `apply` + // (item / shelf / column) leave them in the store; this cleanup runs + // after every terminal path (commit + Esc both unmount via + // `setMovingNode(null)`), so clearing here drops any lingering guide. + useAlignmentGuides.getState().clear() // Same belt-and-suspenders pattern for the wall bridge ghost // previews — clear unconditionally so Esc / mid-drag unmount / // 3D-takeover paths all end up with no stale ghosts left over. @@ -428,7 +434,11 @@ export function FloorplanRegistryMoveOverlay() { // simple translate suffices. const dxProposed = gridX - originalPosition[0] const dzProposed = gridZ - originalPosition[2] - const movingAnchors = bboxAnchors( + // Corner-only for the moving node so it aligns by its edges, never + // its centreline — matching the placement tools and Path 1 move + // sessions. Candidates keep their full 9-point set (we DO want to + // align to a neighbour's centre / edge-midpoints). + const movingAnchors = bboxCornerAnchors( movingNode.id, movingLocalBBox.x + dxProposed, movingLocalBBox.y + dzProposed, diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx new file mode 100644 index 000000000..60b9c5556 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx @@ -0,0 +1,57 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type FloorplanGeometry, + type GeometryContext, + nodeRegistry, + usePlacementPreview, + useScene, +} from '@pascal-app/core' +import { memo } from 'react' +import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' + +/** + * Renders a faint, non-interactive ghost of the node being placed by a + * registry placement tool (e.g. column), following the cursor in the floor + * plan. The 3D view shows a translucent mesh preview; in 2D that mesh is + * hidden (canvas `display:none`), so without this the user only saw the grid + * cursor dot + alignment guides — no sense of the footprint they were about + * to drop. The placement tool publishes a transient, already-positioned + + * aligned node to `usePlacementPreview`; we build its `def.floorplan` + * footprint with a minimal (unselected) context and render it. + * + * Mounted inside the floor-plan scene `` so the geometry's level-local + * meters get the same world→SVG transform every other entry does. + */ +export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() { + const node = usePlacementPreview((s) => s.node) + if (!node) return null + + const builder = nodeRegistry.get(node.type)?.floorplan + if (!builder) return null + + // Minimal, unselected context — preview never shows selection chrome + // (move handles / resize arrows / hatch live behind `viewState.selected`). + const nodes = useScene.getState().nodes + const ctx = { + resolve: (id: AnyNodeId) => nodes[id], + children: [], + siblings: [], + parent: null, + viewState: undefined, + } as unknown as GeometryContext + + const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)( + node, + ctx, + ) + if (!geometry) return null + + return ( + + + + ) +}) 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 04d53d3d8..9a3be98fc 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 @@ -14,6 +14,7 @@ import { pauseSceneHistory, resolveBuildingForLevel, resumeSceneHistory, + useAlignmentGuides, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -310,7 +311,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { rotation: [0, live.rotation, 0] as [number, number, number], parentId: null, } as AnyNode - } else if (node.type === 'slab' || node.type === 'ceiling') { + } else if (node.type === 'column') { + // Same world-plan override as item/shelf, but column stores its + // Y rotation as a scalar (not a tuple). + effectiveNode = { + ...node, + position: live.position, + rotation: live.rotation, + parentId: null, + } as AnyNode + } else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') { const dx = live.position[0] const dz = live.position[2] if (dx !== 0 || dz !== 0) { @@ -653,6 +663,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { resumeSceneHistory(useScene) drag.historyPaused = false } + // Affordances that publish Figma alignment guides during `apply` + // (fence endpoint) leave them in the store on cancel — `canCommit` + // (the pointer-up clear) never runs on a cancel. + useAlignmentGuides.getState().clear() // Drop any live overrides the session may have published. No-op // for affordances whose `apply()` writes straight to scene; the // override-routed sessions (wall endpoint, wall curve) rely on @@ -686,6 +700,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { for (const id of drag.session.affectedIds) overrides.clear(id) dragRef.current = null } + // Clear any alignment guide a session left behind on mid-drag unmount. + useAlignmentGuides.getState().clear() } }, []) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index a020c6a81..48b0a80b7 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -36,6 +36,7 @@ import { StairSegmentNode as StairSegmentNodeSchema, sampleWallCenterline, sceneRegistry, + useAlignmentGuides, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -62,6 +63,7 @@ import { createPortal } from 'react-dom' import { Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' import { + alignFloorplanDraftPoint, buildFloorplanItemEntry, buildFloorplanStairEntry as buildSharedFloorplanStairEntry, collectLevelDescendants, @@ -86,6 +88,7 @@ import { import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer' import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer' import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer' +import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer' import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer' import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer' import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths' @@ -110,6 +113,7 @@ import { createWallOnCurrentLevel, isSegmentLongEnough, snapWallDraftPoint, + snapPointToGrid as snapWallPointToGrid, WALL_FINE_GRID_STEP, WALL_GRID_STEP, type WallPlanPoint, @@ -6315,6 +6319,9 @@ export function FloorplanPanel() { clearWallCurveDrag() clearSiteBoundaryInteraction() setCursorPoint(null) + // Drop any Figma-style alignment guide a draft branch left behind so it + // doesn't linger after the tool deactivates / Esc / draft reset. + useAlignmentGuides.getState().clear() }, [ clearFencePlacementDraft, clearCeilingPlacementDraft, @@ -7407,14 +7414,20 @@ export function FloorplanPanel() { } if (isCeilingBuildActive) { - emitFloorplanGridEvent('move', planPoint, event) - - const snappedPoint = snapPolygonDraftPoint({ + // Polygon vertex: grid (snapToHalf) + optional 45° angle snap from + // the previous vertex. Alignment runs only when angle snap is OFF + // (first vertex, or Shift held) — when the angle is being locked, + // pulling the vertex sideways would break it. + const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed + let snappedPoint = snapPolygonDraftPoint({ point: planPoint, start: ceilingDraftPoints[ceilingDraftPoints.length - 1], - angleSnap: ceilingDraftPoints.length > 0 && !shiftPressed, + angleSnap, }) + if (angleSnap) useAlignmentGuides.getState().clear() + else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) + emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, ) @@ -7422,7 +7435,8 @@ export function FloorplanPanel() { } if (isRoofBuildActive) { - const snappedPoint = getSnappedFloorplanPoint(planPoint) + let snappedPoint = getSnappedFloorplanPoint(planPoint) + snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, @@ -7439,18 +7453,25 @@ export function FloorplanPanel() { } if (isFenceBuildActive) { - emitFloorplanGridEvent('move', planPoint, event) - - // Fence draft: grid snap only — orthogonal fences fall out of - // a grid-aligned start. Shift switches to the fine grid step - // for precision. Mirrors `wall/tool.tsx`. - const snappedPoint = snapFenceDraftPoint({ + // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then + // Figma alignment — same endpoint-wins precedence as the wall branch. + const fenceSnapped = snapFenceDraftPoint({ point: planPoint, walls, fences, step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, }) + const fenceGridBase = snapWallPointToGrid( + planPoint, + shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP, + ) + const fenceLocked = + fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] + let snappedPoint = fenceSnapped + if (fenceLocked) useAlignmentGuides.getState().clear() + else snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey }) + emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, ) @@ -7469,11 +7490,14 @@ export function FloorplanPanel() { // the local polygon-draft state actually updates as the cursor // moves (the catch-all would otherwise swallow the move event). if (isPolygonBuildActive) { - const snappedPoint = snapPolygonDraftPoint({ + const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed + let snappedPoint = snapPolygonDraftPoint({ point: planPoint, start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], - angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, + angleSnap, }) + if (angleSnap) useAlignmentGuides.getState().clear() + else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) // Emit `grid:move` so the registry-driven slab tool also tracks // the cursor (its 3D preview needs it). @@ -7573,14 +7597,26 @@ export function FloorplanPanel() { return } - // Wall draft: grid snap only — orthogonal walls follow naturally - // from a grid-aligned start. Shift switches to the fine grid step - // (0.05m) for precision. - const snappedPoint = snapWallDraftPoint({ + // Wall draft: grid snap (orthogonal walls follow naturally from a + // grid-aligned start; Shift = fine 0.05m step), then Figma-style + // alignment layered on top. An existing wall endpoint / join snap + // wins outright — never pull the cursor off a corner the user is + // closing onto — so alignment runs ONLY when the wall snap left the + // point on the plain grid. Alt bypasses alignment. + const gridStep = shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP + const wallSnapped = snapWallDraftPoint({ point: planPoint, walls, step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, }) + const gridBase = snapWallPointToGrid(planPoint, gridStep) + const lockedToWall = wallSnapped[0] !== gridBase[0] || wallSnapped[1] !== gridBase[1] + let snappedPoint = wallSnapped + if (lockedToWall) { + useAlignmentGuides.getState().clear() + } else { + snappedPoint = alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey }) + } // Emit `grid:move` so the registry-driven wall tool's 3D preview // tracks the cursor. The local draftEnd update below is what @@ -9115,6 +9151,12 @@ export function FloorplanPanel() { would create a measure→fit→measure loop. */} + {/* Faint footprint ghost of the node being placed by a + registry placement tool (e.g. column), following the + cursor. The 3D mesh preview is hidden in 2D, so this is + the only placement visual in the floor plan. See + `floorplan-placement-preview-layer.tsx`. */} + {/* Bridge-wall ghost previews painted on top of the registry layer (drag-time only); cleared by the wall move's `commit()` so real bridges replace diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index be09c7483..0f34e70e0 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -2,9 +2,14 @@ import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core' import { type MouseEvent as ReactMouseEvent, useCallback } from 'react' -import { getPlanPointDistance } from '../../lib/floorplan' +import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' -import { WALL_FINE_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting' +import { + snapPointToGrid as snapWallPointToGrid, + WALL_FINE_GRID_STEP, + WALL_GRID_STEP, + type WallPlanPoint, +} from '../tools/wall/wall-drafting' type UseFloorplanBackgroundPlacementArgs = { activePolygonDraftPoints: WallPlanPoint[] @@ -135,20 +140,28 @@ export function useFloorplanBackgroundPlacement({ } if (isCeilingBuildActive) { - emitFloorplanGridEvent('click', planPoint, event) - - const snappedPoint = snapPolygonDraftPoint({ + // Align the committed vertex the same way the move-preview did, so + // the placed point matches what the user saw. Skip when angle snap + // owns the vertex (matches the move branch). + const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed + let snappedPoint = snapPolygonDraftPoint({ point: planPoint, start: ceilingDraftPoints[ceilingDraftPoints.length - 1], - angleSnap: ceilingDraftPoints.length > 0 && !shiftPressed, + angleSnap, }) + if (!angleSnap) { + snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) + } + emitFloorplanGridEvent('click', snappedPoint, event) handleCeilingPlacementPoint(snappedPoint) return true } if (isRoofBuildActive) { - const snappedPoint = getSnappedFloorplanPoint(planPoint) + const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { + bypass: event.altKey, + }) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) @@ -162,16 +175,25 @@ export function useFloorplanBackgroundPlacement({ } if (isFenceBuildActive) { - emitFloorplanGridEvent('click', planPoint, event) - - // Fence draft: grid snap only; Shift = fine step. See `wall/tool.tsx`. - const snappedPoint = snapFenceDraftPoint({ + // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then + // Figma alignment — endpoint snap wins (same precedence as move). + const fenceSnapped = snapFenceDraftPoint({ point: planPoint, walls, fences, step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, }) + const fenceGridBase = snapWallPointToGrid( + planPoint, + shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP, + ) + const fenceLocked = + fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] + const snappedPoint = fenceLocked + ? fenceSnapped + : alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey }) + emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) if (!fenceDraftStart) { @@ -193,11 +215,15 @@ export function useFloorplanBackgroundPlacement({ // swallow the click and skip local draft state updates — leaving // the 2D draft polygon invisible while the 3D tool builds fine). if (isPolygonBuildActive) { - const snappedPoint = snapPolygonDraftPoint({ + const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed + let snappedPoint = snapPolygonDraftPoint({ point: planPoint, start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], - angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, + angleSnap, }) + if (!angleSnap) { + snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) + } // Emit the grid event so the registry-driven slab tool also // sees the click (parity with ceiling / fence / roof branches @@ -220,12 +246,22 @@ export function useFloorplanBackgroundPlacement({ // / draftEnd state in the floor plan would never update, leaving // the dashed-line draft preview invisible. if (isWallBuildActive) { - // Wall draft: grid snap only; Shift = fine step. See `wall/tool.tsx`. - const snappedPoint = snapWallDraftPoint({ + // Wall draft: grid snap (+ existing-wall endpoint/join snap), then + // Figma alignment — endpoint/join snap wins (same precedence as the + // move-preview branch), so committing onto a corner still works. + const wallSnapped = snapWallDraftPoint({ point: planPoint, walls, step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, }) + const wallGridBase = snapWallPointToGrid( + planPoint, + shiftPressed ? WALL_FINE_GRID_STEP : WALL_GRID_STEP, + ) + const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1] + const snappedPoint = wallLocked + ? wallSnapped + : alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey }) emitFloorplanGridEvent('click', snappedPoint, event) handleWallPlacementPoint(snappedPoint) diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 3dbe5113c..85009ebf8 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -8,6 +8,7 @@ import { type LevelNode, resolveAlignment, useAlignmentGuides, + usePlacementPreview, useScene, } from '@pascal-app/core' import { useEffect, useMemo, useRef } from 'react' @@ -124,6 +125,25 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), []) const previewEdgeGeometry = useMemo(() => createElevatorPreviewEdgeGeometry(), []) + // Default-shaped elevator for the 2D floor-plan placement ghost. The 3D + // preview meshes below are hidden in 2D (canvas `display:none`), so this + // feeds `usePlacementPreview` → `FloorplanPlacementPreviewLayer`, which + // renders the elevator's footprint following the cursor. + const floorplanPreviewNode = useMemo( + () => + ElevatorNode.parse({ + name: 'Elevator', + position: [0, 0, 0], + rotation: 0, + width: DEFAULT_ELEVATOR_WIDTH, + depth: DEFAULT_ELEVATOR_DEPTH, + cabHeight: DEFAULT_ELEVATOR_CAB_HEIGHT, + doorWidth: DEFAULT_ELEVATOR_DOOR_WIDTH, + doorHeight: DEFAULT_ELEVATOR_DOOR_HEIGHT, + }), + [], + ) + useEffect(() => { const currentBuildingId = resolveCurrentBuildingId({ buildingId, @@ -192,6 +212,13 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, cursorRef.current?.position.set(gridX, supportY + GRID_OFFSET, gridZ) previewRef.current?.position.set(gridX, supportY + DEFAULT_ELEVATOR_CAB_HEIGHT / 2, gridZ) + // Publish the 2D floor-plan ghost at the snapped/aligned cursor. + usePlacementPreview.getState().set({ + ...floorplanPreviewNode, + position: [gridX, supportY, gridZ], + rotation: rotationRef.current, + }) + if ( previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) @@ -227,6 +254,9 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, ) alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() + // The placed elevator's footprint now renders for real; drop the ghost + // (the next grid:move re-publishes it for the following placement). + usePlacementPreview.getState().clear() } const onKeyDown = (event: KeyboardEvent) => { @@ -244,6 +274,16 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, sfxEmitter.emit('sfx:item-rotate') rotationRef.current += rotationDelta if (previewRef.current) previewRef.current.rotation.y = rotationRef.current + // Reflect the rotation in the 2D ghost immediately (no pointer move + // needed) by republishing at the last snapped cursor position. + const last = previousGridPosRef.current + if (last) { + usePlacementPreview.getState().set({ + ...floorplanPreviewNode, + position: [last[0], 0, last[1]], + rotation: rotationRef.current, + }) + } } } @@ -256,8 +296,9 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, emitter.off('grid:click', onGridClick) window.removeEventListener('keydown', onKeyDown) useAlignmentGuides.getState().clear() + usePlacementPreview.getState().clear() } - }, [buildingId, levelId, onPlaced]) + }, [buildingId, levelId, onPlaced, floorplanPreviewNode]) return ( diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index b8e88642b..2a0cdee9f 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -136,7 +136,16 @@ export const ToolManager: React.FC = () => { nodeId: AnyNodeId, elevatorBuildingId: BuildingNode['id'], ) => { - setSelection({ buildingId: elevatorBuildingId, selectedIds: [nodeId] }) + // Preserve the active level. `setSelection`'s hierarchy guard nulls + // `levelId` whenever `buildingId` is passed without an explicit + // `levelId` — which deselected the current floor plan the moment an + // elevator was placed. Pass the current level through so the floor + // plan stays selected. + setSelection({ + buildingId: elevatorBuildingId, + levelId: activeLevelId ?? null, + selectedIds: [nodeId], + }) } return ( diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d74ab958e..42f45dc81 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -183,7 +183,12 @@ export { // their own polygon in isolation — the stair (parent) owns the // computation and emits the whole stack as one registry entry. export { + alignFloorplanDraftPoint, + applyFloorplanAlignment, buildFloorplanStairEntry, + FLOORPLAN_ALIGNMENT_THRESHOLD_M, + FLOORPLAN_DRAFT_ALIGN_ID, + type FloorplanAlignmentResult, type FloorplanStairArrowEntry, type FloorplanStairEntry, type FloorplanStairSegmentEntry, diff --git a/packages/editor/src/lib/floorplan/apply-alignment.ts b/packages/editor/src/lib/floorplan/apply-alignment.ts new file mode 100644 index 000000000..a180f68f0 --- /dev/null +++ b/packages/editor/src/lib/floorplan/apply-alignment.ts @@ -0,0 +1,111 @@ +import { + type AlignmentAnchor, + type AlignmentGuide, + collectAlignmentAnchors, + resolveAlignment, + useAlignmentGuides, + useScene, +} from '@pascal-app/core' + +/** + * Fixed Figma-style alignment threshold (meters) for floor-plan placement / + * move — parity with the 3D tools' `ALIGNMENT_THRESHOLD_M`. Pure-2D drafting + * can pass a zoom-scaled `threshold` instead so the magnetic pull stays + * constant in screen pixels across zoom levels. + */ +export const FLOORPLAN_ALIGNMENT_THRESHOLD_M = 0.08 + +export type FloorplanAlignmentResult = { + /** Plan point with the alignment snap delta applied (grid snap should + * already be baked into the input point). */ + point: [number, number] + snapped: boolean + guides: AlignmentGuide[] +} + +/** + * Layer Figma-style alignment on top of an already grid-snapped plan point, + * shared by the 2D move sessions (`*FloorplanMoveTarget`) and the structural + * drafting branches in `floorplan-panel`. + * + * Publishes guides to the `useAlignmentGuides` store as a side effect — set + * on a match, cleared otherwise — so the mounted `FloorplanAlignmentGuideLayer` + * renders them. Returns the adjusted point. When `bypass` is true (Alt held) + * the point is returned unchanged and guides are cleared, matching the + * "No snap" affordance the placement tools advertise. + * + * `candidates` should be gathered ONCE per drag (`collectAlignmentAnchors`); + * the scene is stable during a single drag, so re-collecting per pointer-move + * is wasted work. + */ +export function applyFloorplanAlignment( + point: readonly [number, number], + movingAnchors: AlignmentAnchor[], + candidates: AlignmentAnchor[], + opts?: { bypass?: boolean; threshold?: number }, +): FloorplanAlignmentResult { + if (opts?.bypass) { + useAlignmentGuides.getState().clear() + return { point: [point[0], point[1]], snapped: false, guides: [] } + } + + const result = resolveAlignment({ + moving: movingAnchors, + candidates, + threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M, + }) + + useAlignmentGuides.getState().set(result.guides) + + if (!result.snap) { + return { point: [point[0], point[1]], snapped: false, guides: result.guides } + } + return { + point: [point[0] + result.snap.dx, point[1] + result.snap.dz], + snapped: true, + guides: result.guides, + } +} + +/** Synthetic node id for the in-progress structural-draft vertex. Never + * collides with a real scene node, so `collectAlignmentAnchors` excludes + * nothing real. */ +export const FLOORPLAN_DRAFT_ALIGN_ID = '__floorplan_draft__' + +/** + * Align a single grid-snapped structural-draft vertex (wall / fence / polygon + * / roof endpoint) against every other node's anchors (incl. wall faces from + * the wall-face anchor work). Treats the vertex as one corner anchor, gathers + * candidates from the live scene, publishes guides (cleared on `bypass`), and + * returns the possibly-snapped point. + * + * Used by BOTH the move-preview branch and the click-commit handler so the + * committed vertex lands exactly where the preview showed it. Caller owns the + * per-kind precedence (existing-wall endpoint/join snap wins; angle-snap + * suppresses alignment) and only calls this when alignment should apply. + * + * `excludeIds` drops those nodes' anchors from the candidate pool — used when + * dragging a wall / fence endpoint so the moving endpoint doesn't try to + * align to its own (and its linked siblings') geometry that moves with it. + */ +export function alignFloorplanDraftPoint( + point: readonly [number, number], + opts?: { bypass?: boolean; threshold?: number; excludeIds?: readonly string[] }, +): [number, number] { + if (opts?.bypass) { + useAlignmentGuides.getState().clear() + return [point[0], point[1]] + } + let candidates = collectAlignmentAnchors(useScene.getState().nodes, FLOORPLAN_DRAFT_ALIGN_ID) + if (opts?.excludeIds?.length) { + const excluded = new Set(opts.excludeIds) + candidates = candidates.filter((anchor) => !excluded.has(anchor.nodeId)) + } + const { point: snapped } = applyFloorplanAlignment( + point, + [{ nodeId: FLOORPLAN_DRAFT_ALIGN_ID, kind: 'corner', x: point[0], z: point[1] }], + candidates, + { threshold: opts?.threshold }, + ) + return snapped +} diff --git a/packages/editor/src/lib/floorplan/index.ts b/packages/editor/src/lib/floorplan/index.ts index 846b28e3f..4aa913a53 100644 --- a/packages/editor/src/lib/floorplan/index.ts +++ b/packages/editor/src/lib/floorplan/index.ts @@ -1,3 +1,10 @@ +export { + alignFloorplanDraftPoint, + applyFloorplanAlignment, + FLOORPLAN_ALIGNMENT_THRESHOLD_M, + FLOORPLAN_DRAFT_ALIGN_ID, + type FloorplanAlignmentResult, +} from './apply-alignment' export { clampPlanValue, doesPolygonIntersectSelectionBounds, diff --git a/packages/nodes/src/ceiling/floorplan-move.ts b/packages/nodes/src/ceiling/floorplan-move.ts index 2bf473fe9..a4dfd8b60 100644 --- a/packages/nodes/src/ceiling/floorplan-move.ts +++ b/packages/nodes/src/ceiling/floorplan-move.ts @@ -1,88 +1,14 @@ -import { - type AnyNodeId, - type CeilingNode, - type FloorplanMoveTarget, - type FloorplanMoveTargetSession, - sceneRegistry, - useLiveTransforms, - useScene, -} from '@pascal-app/core' -import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' -import type * as THREE from 'three' +import type { CeilingNode, FloorplanMoveTarget } from '@pascal-app/core' +import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move' /** - * 2D floor-plan move handler for ceiling — mirrors the 3D `MoveCeilingTool` - * live-drag pattern. See the equivalent module in `slab/floorplan-move.ts` - * for the full rationale; the only ceiling-specific detail is the - * preserved Y offset (`CeilingSystem` positions the mesh at `height − 0.01` - * on rebuild, so the direct `mesh.position.y` mirrors that to avoid a - * vertical teleport when the React group position is reconciled). + * 2D floor-plan move handler for ceiling. Delegates to the shared polygon + * centroid-pivot mover (same pivot semantics as slab / items). See + * `shared/polygon-centroid-move.ts` for the rationale. + * + * `meshY = height − 0.01`: `CeilingSystem` parks the ceiling group at that Y + * on rebuild, so mirroring it during the drag avoids a vertical teleport in + * split view. */ -const GRID_STEP = 0.5 - -function translatePolygon( - polygon: ReadonlyArray, - dx: number, - dz: number, -): Array<[number, number]> { - return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]) -} - -export const ceilingFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { - const ceilingId = node.id as AnyNodeId - const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number]) - const originalHoles = (node.holes ?? []).map((hole) => - hole.map(([x, z]) => [x, z] as [number, number]), - ) - const height = node.height ?? 2.5 - let anchor: [number, number] | null = null - let lastDelta: [number, number] = [0, 0] - - const session: FloorplanMoveTargetSession = { - affectedIds: [ceilingId], - apply({ planPoint, modifiers }) { - const snapped: WallPlanPoint = modifiers.shiftKey - ? ([planPoint[0], planPoint[1]] as WallPlanPoint) - : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) - if (!anchor) { - anchor = [snapped[0], snapped[1]] - return - } - const dx = snapped[0] - anchor[0] - const dz = snapped[1] - anchor[1] - lastDelta = [dx, dz] - useLiveTransforms.getState().set(ceilingId, { - position: [dx, 0, dz], - rotation: 0, - }) - const mesh = sceneRegistry.nodes.get(ceilingId) as THREE.Object3D | undefined - // Preserve ceiling height — `CeilingSystem` sets `mesh.position.y = - // height − 0.01` on each rebuild; mirror that during the drag so - // the mesh stays at ceiling height (not collapsed to y=0). - if (mesh) mesh.position.set(dx, height - 0.01, dz) - }, - canCommit() { - const live = useScene.getState().nodes[ceilingId] as CeilingNode | undefined - if (!live || live.type !== 'ceiling') return false - const [dx, dz] = lastDelta - if (dx === 0 && dz === 0) return false - // Sync commit sequence — see `slab/floorplan-move.ts` for the - // full ordering rationale (scene write → direct markDirty → - // useLiveTransforms.clear, all sync in this handler so React - // render + CeilingSystem rebuild land in the same paint). - useScene.getState().updateNodes([ - { - id: ceilingId, - data: { - polygon: translatePolygon(originalPolygon, dx, dz), - holes: originalHoles.map((h) => translatePolygon(h, dx, dz)), - }, - }, - ]) - useScene.getState().markDirty(ceilingId) - useLiveTransforms.getState().clear(ceilingId) - return true - }, - } - return session -} +export const ceilingFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => + createPolygonCentroidMoveTarget({ node, nodes, meshY: (node.height ?? 2.5) - 0.01 }) diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index 51bb2e33f..2aa8f3121 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -6,6 +6,7 @@ import { } from '@pascal-app/core' import { buildColumnFloorplan } from './floorplan' import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances' +import { columnFloorplanMoveTarget } from './floorplan-move' import { columnParametrics } from './parametrics' import { ColumnNode } from './schema' @@ -338,15 +339,17 @@ export const columnDefinition: NodeDefinition = { { key: 'Esc', label: 'Cancel' }, ], floorplan: buildColumnFloorplan, + // 2D body move routes through this kind-specific target so the column + // aligns by its footprint *edges* (and snaps flush to wall faces) instead + // of the overlay's generic free-translate path, which aligned by bbox + // centre and gathered candidates from SVG bounding boxes only. Mirrors the + // shelf move target. + floorplanMoveTarget: columnFloorplanMoveTarget, // 2D drag affordances — `column-resize` handles every dimension arrow // the floor-plan builder emits per cross-section / support style (the // payload's `dim` field discriminates radius / uniform / width / depth // / brace-width / brace-depth / spreads). `column-rotate` powers the - // corner rotate-arrow. Body move continues to flow through the - // orange move-handle dot via the registry overlay's generic - // free-translate path — columns don't need a kind-specific - // `floorplanMoveTarget` since they have no linked-cascade - // requirements like wall. + // corner rotate-arrow. floorplanAffordances: { 'column-resize': columnResizeAffordance, 'column-rotate': columnRotateAffordance, diff --git a/packages/nodes/src/column/floorplan-move.ts b/packages/nodes/src/column/floorplan-move.ts new file mode 100644 index 000000000..fbf845fe4 --- /dev/null +++ b/packages/nodes/src/column/floorplan-move.ts @@ -0,0 +1,92 @@ +import { + type AnyNode, + type AnyNodeId, + type ColumnNode, + collectAlignmentAnchors, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + movingFootprintAnchors, + useScene, +} from '@pascal-app/core' +import { + applyFloorplanAlignment, + snapPointToGrid, + triggerSFX, + type WallPlanPoint, +} from '@pascal-app/editor' + +/** + * 2D floor-plan move handler for column — mirrors `itemFloorplanMoveTarget`: + * each pointermove writes the absolute world-plan position straight to + * `useScene` (history paused by the overlay). The 2D SVG and the 3D group + * transform both read `node.position` reactively, so they stay in lockstep; + * the overlay's snapshot-diff makes the drag one undoable step. `canCommit` + * only validates. + * + * Columns previously fell through to the overlay's generic free-translate + * path, which aligned a column by its bbox *centre* and gathered candidates + * from SVG bounding boxes only (missing wall faces / diagonal walls). Routing + * through a kind-specific target gives column the same footprint-edge + * alignment as shelf / item — including snapping flush to wall faces (the + * pillar↔wall case this whole feature targets). + * + * Earlier this used the `useLiveTransforms` + imperative-mesh pattern; for a + * `position`-field kind that leaves the 3D group stuck at the old spot on + * commit (nothing reconciles it off the cleared live transform, since the + * geometry doesn't rebuild on a position-only change). See the shelf handler + * for the full rationale. + * + * Column stores rotation as a scalar (not a tuple); position is `[x, y, z]`. + */ + +const GRID_STEP = 0.5 + +export const columnFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => { + const columnId = node.id as AnyNodeId + const originalPosition: [number, number, number] = [...node.position] as [number, number, number] + const rotationY = node.rotation ?? 0 + let lastPosition: [number, number, number] = originalPosition + let lastSnapKey: string | null = null + + // Alignment candidates gathered once — scene is stable during the drag. + const candidates = collectAlignmentAnchors(nodes, columnId) + + const session: FloorplanMoveTargetSession = { + affectedIds: [columnId], + apply({ planPoint, modifiers }) { + const gridSnapped: WallPlanPoint = modifiers.shiftKey + ? ([planPoint[0], planPoint[1]] as WallPlanPoint) + : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + // Figma-style alignment layered on the grid snap (Alt bypasses). + const { point: snapped } = applyFloorplanAlignment( + gridSnapped, + movingFootprintAnchors( + node as unknown as AnyNode, + gridSnapped[0], + gridSnapped[1], + rotationY, + ), + candidates, + { bypass: modifiers.altKey }, + ) + const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] + lastPosition = next + + const snapKey = `${snapped[0]},${snapped[1]}` + if (snapKey !== lastSnapKey) { + triggerSFX('sfx:grid-snap') + lastSnapKey = snapKey + } + // Single source of truth — write the absolute position straight to the + // scene (history paused by the overlay). 2D SVG and 3D group transform + // both follow `node.position` reactively, so they can't diverge. + useScene.getState().updateNodes([{ id: columnId, data: { position: next } }]) + }, + canCommit() { + const live = useScene.getState().nodes[columnId] as ColumnNode | undefined + if (!live || live.type !== 'column') return false + return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) + }, + } + return session +} diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 8d5a9fc34..5c8c9a22f 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -11,6 +11,7 @@ import { resolveAlignment, snapPointToGrid, useAlignmentGuides, + usePlacementPreview, useScene, } from '@pascal-app/core' import { triggerSFX } from '@pascal-app/editor' @@ -93,6 +94,12 @@ const ColumnTool = () => { cursorRef.current?.position.set(ax, event.localPosition[1], az) + // Publish a transient, positioned preview node for the 2D floor-plan + // ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan + // placement-preview layer renders this node's footprint at the snapped, + // aligned cursor so users see the pillar before they click. + usePlacementPreview.getState().set({ ...previewNode, position: [ax, 0, az] }) + const prev = previousSnapRef.current if (!prev || prev[0] !== ax || prev[1] !== az) { triggerSFX('sfx:grid-snap') @@ -122,9 +129,11 @@ const ColumnTool = () => { useViewer.getState().setSelection({ selectedIds: [column.id] }) triggerSFX('sfx:structure-build') // The placed column is now a valid alignment target for the next one; - // refresh the candidate pool and drop the guide from this drop. + // refresh the candidate pool and drop the guide from this drop. The + // 2D ghost re-publishes on the next move. alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) useAlignmentGuides.getState().clear() + usePlacementPreview.getState().clear() } emitter.on('grid:move', onGridMove) @@ -134,6 +143,7 @@ const ColumnTool = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) useAlignmentGuides.getState().clear() + usePlacementPreview.getState().clear() } }, [activeLevelId, previewNode]) diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 93b3102f7..ac7bc4226 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -6,7 +6,7 @@ import { useScene, } from '@pascal-app/core' import { snapToHalf } from '@pascal-app/editor' -import { findClosestWallInPlan } from '../shared/wall-attach-target' +import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target' import { clampToWall, hasWallChildOverlap } from './door-math' /** @@ -55,8 +55,20 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) if (!hit) return // pointer off any wall — keep door at last valid position - // Snap the wall-local X to 0.5m grid (Shift bypasses). - const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX) + // Figma-style along-wall alignment first (edge-to-edge with other + // openings / wall ends); it competes with — and wins over — the 0.5m + // grid snap. Falls back to the grid snap when nothing aligns. Alt + // bypasses; Shift drops the grid snap for fine positioning. + const neighborX = modifiers.altKey + ? null + : snapLocalXToNeighbors({ + wall: hit.wall, + localX: hit.localX, + width: node.width, + selfId: node.id as AnyNodeId, + nodes, + }) + const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) lastValid = { diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index 66d46ae96..7ca4e1473 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -7,11 +7,13 @@ import { getMaxWallCurveOffset, getWallChordFrame, normalizeWallCurveOffset, + useAlignmentGuides, useLiveNodeOverrides, useScene, type WallNode, } from '@pascal-app/core' import { + alignFloorplanDraftPoint, type FencePlanPoint, getSegmentGridStep, isSegmentLongEnough, @@ -164,15 +166,23 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance = { ignoreFenceIds: [node.id], step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined, }) - const nextStart = endpoint === 'start' ? snapped : fixedPoint - const nextEnd = endpoint === 'end' ? snapped : fixedPoint + // Figma-style alignment on the dragged endpoint — snaps it onto + // another object's edge / wall face and publishes a guide, matching + // the 3D fence endpoint action. The dragged fence and its linked + // siblings (which cascade with the endpoint) are excluded from the + // candidate pool. Alt is reserved for detach here, NOT bypass. + const aligned = alignFloorplanDraftPoint(snapped, { + excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)], + }) as FencePlanPoint + const nextStart = endpoint === 'start' ? aligned : fixedPoint + const nextEnd = endpoint === 'end' ? aligned : fixedPoint const linkedUpdates = modifiers.altKey ? [] : linkedOriginals.map((l) => ({ id: l.id, - start: pointsNearlyEqual(l.start, originalMovingPoint) ? snapped : l.start, - end: pointsNearlyEqual(l.end, originalMovingPoint) ? snapped : l.end, + start: pointsNearlyEqual(l.start, originalMovingPoint) ? aligned : l.start, + end: pointsNearlyEqual(l.end, originalMovingPoint) ? aligned : l.end, })) useScene.getState().updateNodes([ @@ -184,6 +194,9 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance = { ]) }, canCommit() { + // Pointer-up always runs canCommit — drop the alignment guide here + // so it doesn't linger after a commit / reject. + useAlignmentGuides.getState().clear() const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined return ( !!finalFence && diff --git a/packages/nodes/src/item/floorplan-move.ts b/packages/nodes/src/item/floorplan-move.ts index 2a8aad5c1..039509b02 100644 --- a/packages/nodes/src/item/floorplan-move.ts +++ b/packages/nodes/src/item/floorplan-move.ts @@ -2,14 +2,16 @@ import { type AnyNode, type AnyNodeId, type CeilingNode, + collectAlignmentAnchors, type FloorplanMoveTarget, type FloorplanMoveTargetSession, getScaledDimensions, type ItemNode, + movingFootprintAnchors, useScene, } from '@pascal-app/core' -import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' -import { findClosestWallInPlan } from '../shared/wall-attach-target' +import { applyFloorplanAlignment, snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' +import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target' /** * 2D floor-plan move handler for item. Branches on `asset.attachTo`: @@ -34,7 +36,7 @@ import { findClosestWallInPlan } from '../shared/wall-attach-target' const GRID_STEP = 0.5 -export const itemFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { +export const itemFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => { const attachTo = node.asset.attachTo const startLevelId: AnyNodeId | null = (() => { // Walk to the owning level depending on the item's current parent: @@ -64,7 +66,7 @@ export const itemFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) if (attachTo === 'ceiling') { return buildSurfaceItemSession(node, startLevelId, 'ceiling') } - return buildFloorItemSession(node, startLevelId) + return buildFloorItemSession(node, startLevelId, nodes) } function buildWallItemSession( @@ -83,11 +85,24 @@ function buildWallItemSession( const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) if (!hit) return - const snappedLocalX = modifiers.shiftKey - ? hit.localX - : Math.round(hit.localX / GRID_STEP) * GRID_STEP - const [width] = getScaledDimensions(node) + + // Figma-style along-wall alignment (edge-to-edge with other openings / + // wall items / wall ends), winning over the 0.5m grid snap; falls back + // to grid when nothing aligns. Alt bypasses; Shift drops the grid snap. + const neighborX = modifiers.altKey + ? null + : snapLocalXToNeighbors({ + wall: hit.wall, + localX: hit.localX, + width, + selfId: node.id as AnyNodeId, + nodes, + }) + const snappedLocalX = + neighborX ?? + (modifiers.shiftKey ? hit.localX : Math.round(hit.localX / GRID_STEP) * GRID_STEP) + const halfW = width / 2 const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX)) @@ -125,13 +140,29 @@ function buildWallItemSession( function buildFloorItemSession( node: ItemNode, startLevelId: AnyNodeId | null, + nodes: Record, ): FloorplanMoveTargetSession { + const rotationY = node.rotation[1] ?? 0 + // Alignment candidates gathered once — scene is stable during the drag. + const candidates = collectAlignmentAnchors(nodes, node.id) return { affectedIds: [node.id as AnyNodeId], apply({ planPoint, modifiers }) { - const snapped: WallPlanPoint = modifiers.shiftKey + const gridSnapped: WallPlanPoint = modifiers.shiftKey ? ([planPoint[0], planPoint[1]] as WallPlanPoint) : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + // Figma-style alignment layered on the grid snap (Alt bypasses). + const { point: snapped } = applyFloorplanAlignment( + gridSnapped, + movingFootprintAnchors( + node as unknown as AnyNode, + gridSnapped[0], + gridSnapped[1], + rotationY, + ), + candidates, + { bypass: modifiers.altKey }, + ) const sourceY = node.position[1] const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]] diff --git a/packages/nodes/src/shared/polygon-centroid-move.ts b/packages/nodes/src/shared/polygon-centroid-move.ts new file mode 100644 index 000000000..1155a1ce5 --- /dev/null +++ b/packages/nodes/src/shared/polygon-centroid-move.ts @@ -0,0 +1,143 @@ +import { + type AnyNode, + type AnyNodeId, + collectAlignmentAnchors, + type FloorplanMoveTargetSession, + polygonAnchors, + resolveAlignment, + sceneRegistry, + useAlignmentGuides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' +import type * as THREE from 'three' + +/** + * Shared 2D floor-plan move for polygon-based kinds (slab / ceiling / zone). + * + * **Pivot semantics.** The move uses the polygon's **centroid** as the pivot: + * the centroid snaps to the (grid-snapped, then Figma-aligned) cursor — the + * same way a regular item's origin snaps to the cursor in both 3D and 2D. + * This replaces the old grab-relative delta ("drag from wherever you first + * touched"), so polygon kinds move consistently with every other item. + * + * **Why a delta in `useLiveTransforms`** (see `wiki/architecture/tools.md`): + * polygon kinds carry their position in their vertices, not a `position` + * field. The live preview translates the rendered `` by the delta + * (`ParametricNodeRenderer` consumes `useLiveTransforms.position` as the + * group position) so the SVG follows the EXACT snapped result with no CSG + * rebuild per tick. On commit we write the translated polygon once. Because + * the live delta and the committed polygon are derived from the same + * `lastDelta`, the visual and the commit always agree. + * + * `meshY` mirrors the value the kind's system sets on rebuild (slab: 0; + * ceiling: `height − 0.01`) so the 3D mesh doesn't teleport vertically in a + * split view during the drag. + */ +const GRID_STEP = 0.5 + +/** Figma-style alignment threshold (meters) — parity with the 3D move tools. */ +const ALIGNMENT_THRESHOLD_M = 0.08 + +function translatePolygon( + polygon: ReadonlyArray, + dx: number, + dz: number, +): Array<[number, number]> { + return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]) +} + +/** Average of the polygon's vertices — matches the 3D `MoveSlabTool` pivot. */ +function polygonCentroid(polygon: ReadonlyArray): [number, number] { + if (polygon.length === 0) return [0, 0] + let sumX = 0 + let sumZ = 0 + for (const [x, z] of polygon) { + sumX += x + sumZ += z + } + return [sumX / polygon.length, sumZ / polygon.length] +} + +export function createPolygonCentroidMoveTarget(args: { + node: { + id: string + type: string + polygon: Array<[number, number]> + holes?: Array> + } + nodes: Record + /** 3D mesh Y the kind's system parks the group at on rebuild. */ + meshY: number +}): FloorplanMoveTargetSession { + const { node, nodes, meshY } = args + const id = node.id as AnyNodeId + const typeGuard = node.type + const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number]) + const hasHoles = Array.isArray(node.holes) + const originalHoles = (node.holes ?? []).map((hole) => + hole.map(([x, z]) => [x, z] as [number, number]), + ) + const originalCenter = polygonCentroid(originalPolygon) + // Alignment candidates gathered once — the scene is stable during the drag. + const candidates = collectAlignmentAnchors(nodes, id) + let lastDelta: [number, number] = [0, 0] + + return { + affectedIds: [id], + apply({ planPoint, modifiers }) { + // Centroid → snapped cursor. Grid-snap the target centroid (Shift + // drops the grid snap), then layer Figma alignment on the translated + // polygon's vertices and fold its snap into the delta. Alt bypasses. + const target: WallPlanPoint = modifiers.shiftKey + ? ([planPoint[0], planPoint[1]] as WallPlanPoint) + : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + let dx = target[0] - originalCenter[0] + let dz = target[1] - originalCenter[1] + + if (!modifiers.altKey && candidates.length > 0) { + const result = resolveAlignment({ + moving: polygonAnchors(id, translatePolygon(originalPolygon, dx, dz)), + candidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + dx += result.snap.dx + dz += result.snap.dz + } + useAlignmentGuides.getState().set(result.guides) + } else { + useAlignmentGuides.getState().clear() + } + + lastDelta = [dx, dz] + // Live-drag exception: write the delta to BOTH `useLiveTransforms` + // (React source of truth) and the mesh (direct Three.js) so they don't + // fight per frame. + useLiveTransforms.getState().set(id, { position: [dx, 0, dz], rotation: 0 }) + const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined + if (mesh) mesh.position.set(dx, meshY, dz) + }, + canCommit() { + const live = useScene.getState().nodes[id] as { type?: string } | undefined + if (!live || live.type !== typeGuard) return false + const [dx, dz] = lastDelta + if (dx === 0 && dz === 0) return false + // Sync commit: scene write → direct markDirty → clear live transform, + // so the React render and the kind's geometry rebuild land in the same + // paint (no original-position blink). Only write `holes` for kinds that + // have them (zone has none). + const data: { polygon: Array<[number, number]>; holes?: Array> } = { + polygon: translatePolygon(originalPolygon, dx, dz), + } + if (hasHoles) { + data.holes = originalHoles.map((h) => translatePolygon(h, dx, dz)) + } + useScene.getState().updateNodes([{ id, data }]) + useScene.getState().markDirty(id) + useLiveTransforms.getState().clear(id) + return true + }, + } +} diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index 717961194..1dcab28cf 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -1,4 +1,11 @@ -import { type AnyNode, type AnyNodeId, isCurvedWall, type WallNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + getScaledDimensions, + type ItemNode, + isCurvedWall, + type WallNode, +} from '@pascal-app/core' /** * Shared helpers for the kinds whose 2D move snaps onto a wall in plan @@ -127,3 +134,83 @@ export function findClosestWallInPlan( return best } + +/** Figma-style along-wall alignment threshold (meters) — parity with the + * XZ placement / move threshold. */ +const ALONG_WALL_ALIGN_THRESHOLD_M = 0.08 + +/** The along-wall span of a wall-hosted node (door / window / wall item): + * its centre `localX` and half-width. `null` for kinds with no along-wall + * footprint. */ +function wallAttachmentSpan(node: AnyNode): { center: number; half: number } | null { + if (node.type === 'door' || node.type === 'window') { + const n = node as { position: [number, number, number]; width: number } + return { center: n.position[0], half: n.width / 2 } + } + if (node.type === 'item') { + const item = node as ItemNode + const attachTo = item.asset.attachTo + if (attachTo !== 'wall' && attachTo !== 'wall-side') return null + const [w] = getScaledDimensions(item) + return { center: item.position[0], half: w / 2 } + } + return null +} + +/** + * Figma-style alignment for a wall-hosted opening / item, along the wall + * axis. Snaps the moving node's edges (or centre) to other attachments' + * edges/centres on the same wall, plus the wall ends. Edge-to-edge first, + * so two doors line up flush. + * + * Returns the adjusted `localX` when a neighbour stop is within threshold, + * or `null` when nothing aligns — callers treat `null` as "no alignment, + * fall back to the grid snap". This lets along-wall alignment COMPETE with + * the 0.5m grid (openings have arbitrary widths rarely on the grid, so + * layering on top of the grid snap would almost never trigger). + * + * Snap-only for v1 — no guide is published (the floor-plan guide layer + * renders XZ guides; an along-wall guide on a diagonal wall needs extra + * projection work, deferred). + */ +export function snapLocalXToNeighbors(args: { + wall: WallNode + localX: number + width: number + selfId: AnyNodeId + nodes: Record + threshold?: number +}): number | null { + const { wall, localX, width, selfId, nodes, threshold = ALONG_WALL_ALIGN_THRESHOLD_M } = args + const half = width / 2 + const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + + // Candidate stops along the wall: both ends + every other attachment's + // edges and centre. + const candidateStops: number[] = [0, wallLength] + for (const node of Object.values(nodes)) { + if (!node || node.id === selfId) continue + if ((node as { parentId?: string }).parentId !== wall.id) continue + const span = wallAttachmentSpan(node) + if (!span) continue + candidateStops.push(span.center - span.half, span.center, span.center + span.half) + } + + // Moving stops: our two edges (edge-to-edge alignment) + centre. + const movingStops = [localX - half, localX, localX + half] + + let bestDelta: number | null = null + let bestAbs = threshold + for (const ms of movingStops) { + for (const cs of candidateStops) { + const d = cs - ms + const ad = Math.abs(d) + if (ad <= bestAbs && (bestDelta === null || ad < bestAbs)) { + bestAbs = ad + bestDelta = d + } + } + } + + return bestDelta === null ? null : localX + bestDelta +} diff --git a/packages/nodes/src/shelf/floorplan-move.ts b/packages/nodes/src/shelf/floorplan-move.ts index ee43750d7..21715cc49 100644 --- a/packages/nodes/src/shelf/floorplan-move.ts +++ b/packages/nodes/src/shelf/floorplan-move.ts @@ -1,51 +1,76 @@ import { + type AnyNode, type AnyNodeId, + collectAlignmentAnchors, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + movingFootprintAnchors, type ShelfNode, - sceneRegistry, - useLiveTransforms, useScene, } from '@pascal-app/core' -import { snapPointToGrid, triggerSFX, type WallPlanPoint } from '@pascal-app/editor' -import type * as THREE from 'three' +import { + applyFloorplanAlignment, + snapPointToGrid, + triggerSFX, + type WallPlanPoint, +} from '@pascal-app/editor' /** - * 2D floor-plan move handler for shelf — behaves like items in the - * floor-plan move flow: + * 2D floor-plan move handler for shelf — mirrors `itemFloorplanMoveTarget`, + * because shelf is a `position`-field kind (it carries its location in + * `node.position`, not in polygon vertices): * - * - Each pointermove writes the absolute world-plan target position - * to `useLiveTransforms` (so the 2D layer's `effectiveNode` override - * re-renders the SVG at the new position) AND mutates the - * registered mesh's `position` directly (so the 3D view mirrors the - * drag in real time). - * - On commit, `canCommit` writes the final position to `scene` as a - * single tracked update — the dispatcher's snapshot-diff captures - * it as one undoable step. - * - On any non-commit unmount (escape, abnormal teardown) the - * dispatcher clears `useLiveTransforms` for affectedIds, so the 3D - * visual snaps back to the reverted scene state. + * - Each pointermove writes the absolute world-plan position straight + * to `useScene` (history is paused by the overlay). This is the single + * source of truth: the 2D `FloorplanRegistryLayer` and the 3D + * `ParametricNodeRenderer` group transform both follow it reactively, + * so 2D and 3D can never diverge. + * - On commit, the overlay's snapshot-diff reverts to baseline, resumes + * history, and re-applies the final position as one undoable step. + * `canCommit` only validates. * - * Unlike `slab` / `ceiling`, this writes the **absolute** position (the - * shelf carries its location in `node.position`, not in polygon - * vertices). The 2D layer's override branch for `shelf` mirrors `item`'s - * world-plan handling. + * Earlier this used the `useLiveTransforms` + imperative-mesh pattern that + * `slab` / `ceiling` use. That works for polygon kinds because their commit + * rebuilds geometry (the vertices change), which forces the 3D group to + * reconcile. Shelf's `geometryKey` excludes `position`, so its commit + * `markDirty` is a no-op and nothing reconciled the 3D group off the cleared + * live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the + * scene directly removes that second source of truth entirely. */ const GRID_STEP = 0.5 -export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { +export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => { const shelfId = node.id as AnyNodeId const originalPosition: [number, number, number] = [...node.position] as [number, number, number] const originalRotationY = node.rotation[1] ?? 0 let lastPosition: [number, number, number] = originalPosition let lastSnapKey: string | null = null + // Alignment candidates — corner/edge/segment anchors of every OTHER node + // (incl. wall faces). Gathered once: the scene is stable during the drag + // (only the shelf moves), so re-collecting per tick is wasted work. + const candidates = collectAlignmentAnchors(nodes, shelfId) + const session: FloorplanMoveTargetSession = { affectedIds: [shelfId], apply({ planPoint, modifiers }) { - const snapped: WallPlanPoint = modifiers.shiftKey + const gridSnapped: WallPlanPoint = modifiers.shiftKey ? ([planPoint[0], planPoint[1]] as WallPlanPoint) : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + // Figma-style alignment layered on the grid snap — the shelf footprint + // edges snap to neighbours / wall faces and a guide is published. Alt + // bypasses (matches placement tools' "No snap"). + const { point: snapped } = applyFloorplanAlignment( + gridSnapped, + movingFootprintAnchors( + node as unknown as AnyNode, + gridSnapped[0], + gridSnapped[1], + originalRotationY, + ), + candidates, + { bypass: modifiers.altKey }, + ) const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] lastPosition = next @@ -57,44 +82,22 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node triggerSFX('sfx:grid-snap') lastSnapKey = snapKey } - // Live preview — same shape items use. `useLiveTransforms.position` - // holds world-plan coords (level-local); the 2D `FloorplanRegistryLayer` - // override for `shelf` reads this and re-renders the SVG entry. - useLiveTransforms.getState().set(shelfId, { - position: next, - rotation: originalRotationY, - }) - // Mirror to the 3D mesh so split-view follows the cursor without - // touching scene state per tick (no CSG, no React re-render of - // geometry — same imperative live-drag pattern as the 3D - // `MoveRegistryNodeTool`). - const mesh = sceneRegistry.nodes.get(shelfId) as THREE.Object3D | undefined - if (mesh) mesh.position.set(next[0], next[1], next[2]) - }, - canCommit() { - const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined - if (!live || live.type !== 'shelf') return false - if (lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) { - return false - } - // Side-effect commit — write final position. The dispatcher's - // snapshot-diff right after `canCommit` returns picks this up as - // the single tracked change for undo. `useLiveTransforms` is - // cleared in the dispatcher's commit path (and in our - // abnormal-unmount cleanup) so the 3D view reconciles to the - // committed scene position on the next render. + // Single source of truth — write the absolute position straight to + // the scene (history is paused by the overlay). Both the 2D SVG and + // the 3D group transform read `node.position` reactively, so they + // stay in lockstep. The overlay's snapshot-diff turns the whole drag + // into one undoable step on commit. useScene.getState().updateNodes([ { id: shelfId, - data: { position: lastPosition }, + data: { position: next }, }, ]) - // The shelf's geometry doesn't depend on `position` (it's the - // group's transform, not the build inputs), but we mark dirty so - // any sibling-aware system that does watch position re-runs. - useScene.getState().markDirty(shelfId) - useLiveTransforms.getState().clear(shelfId) - return true + }, + canCommit() { + const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined + if (!live || live.type !== 'shelf') return false + return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) }, } return session diff --git a/packages/nodes/src/slab/floorplan-move.ts b/packages/nodes/src/slab/floorplan-move.ts index 99fe877d6..f0725a049 100644 --- a/packages/nodes/src/slab/floorplan-move.ts +++ b/packages/nodes/src/slab/floorplan-move.ts @@ -1,131 +1,14 @@ -import { - type AnyNodeId, - type FloorplanMoveTarget, - type FloorplanMoveTargetSession, - type SlabNode, - sceneRegistry, - useLiveTransforms, - useScene, -} from '@pascal-app/core' -import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' -import type * as THREE from 'three' +import type { FloorplanMoveTarget, SlabNode } from '@pascal-app/core' +import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move' /** - * 2D floor-plan move handler for slab — mirrors the 3D `MoveSlabTool` - * live-drag pattern so the visual stays smooth in split view. + * 2D floor-plan move handler for slab. Delegates to the shared polygon + * centroid-pivot mover: the slab's centroid snaps to the (grid-snapped, + * Figma-aligned) cursor — the same pivot semantics as a regular item's + * origin — instead of the old grab-relative delta. See + * `shared/polygon-centroid-move.ts` for the live-drag / commit rationale. * - * **Why not write the polygon every tick?** Per-tick `scene.update` on - * `polygon` triggers a CSG geometry rebuild in `GeometrySystem` every - * frame. Even with a synchronous `markDirty`, the rebuild dispose/add - * pair flickers in the 3D viewer and the slab visibly catches up to - * the cursor one frame late — the same regression `commit f4ea07e` was - * fixed for in the 3D mover. The fix there: don't touch `scene` during - * the drag at all. Translate the rendered `` via the live-drag - * exception (`mesh.position` + `useLiveTransforms.position = delta`). - * On commit, write the polygon once. - * - * **Delta semantics** (see `wiki/architecture/tools.md` — "useLiveTransforms - * contract is per-kind, not generic"): polygon-based kinds carry their - * "position" in the polygon vertices, not a node.position field. The - * `useLiveTransforms.position` must be a translation **delta** - * (`[Δx, 0, Δz]`), which `ParametricNodeRenderer` consumes as the group - * position. Visual = group.position + group.children-in-original-coords - * = (delta) + (original polygon vertices) = translated, with no - * geometry rebuild. - * - * **Commit path**: `canCommit` is the only side-effectful write to - * `scene`. The dispatcher captured snapshots before the first apply, - * so its snapshot-diff after `canCommit` returns will see one update - * (the translated polygon) and run the single-undo dance against it. - * `MoveSlabTool`'s cleanup (fires when `setMovingNode(null)` runs after - * the commit) handles the `useLiveTransforms.clear` + the React-render - * that resets `group.position` to (0,0,0) — by then `GeometrySystem` - * has rebuilt with the new polygon, so the visual lands at the same - * world position with no teleport. + * `meshY = 0`: `GeometrySystem` parks the slab group at y=0 on rebuild. */ -const GRID_STEP = 0.5 - -function translatePolygon( - polygon: ReadonlyArray, - dx: number, - dz: number, -): Array<[number, number]> { - return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]) -} - -export const slabFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { - const slabId = node.id as AnyNodeId - const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number]) - const originalHoles = (node.holes ?? []).map((hole) => - hole.map(([x, z]) => [x, z] as [number, number]), - ) - let anchor: [number, number] | null = null - let lastDelta: [number, number] = [0, 0] - - const session: FloorplanMoveTargetSession = { - affectedIds: [slabId], - apply({ planPoint, modifiers }) { - const snapped: WallPlanPoint = modifiers.shiftKey - ? ([planPoint[0], planPoint[1]] as WallPlanPoint) - : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) - if (!anchor) { - anchor = [snapped[0], snapped[1]] - return - } - const dx = snapped[0] - anchor[0] - const dz = snapped[1] - anchor[1] - lastDelta = [dx, dz] - // Live-drag exception (wiki/architecture/tools.md): write the - // delta to BOTH `mesh.position` (direct Three.js mutation) and - // `useLiveTransforms.position` (React-bound source of truth). - // They MUST match — `ParametricNodeRenderer` re-renders on every - // useLiveTransforms change and reconciles ``, - // so a divergence makes the two writes fight every frame. - useLiveTransforms.getState().set(slabId, { - position: [dx, 0, dz], - rotation: 0, - }) - const mesh = sceneRegistry.nodes.get(slabId) as THREE.Object3D | undefined - if (mesh) mesh.position.set(dx, 0, dz) - }, - canCommit() { - const live = useScene.getState().nodes[slabId] as SlabNode | undefined - if (!live || live.type !== 'slab') return false - const [dx, dz] = lastDelta - if (dx === 0 && dz === 0) return false - // Side-effect commit sequence — mirrors `MoveSlabTool.onGridClick` - // so the React render that clears `group.position` (via the - // useLiveTransforms.clear below) and the `GeometrySystem` rebuild - // (via the sync `markDirty`) land in the same paint cycle. Order - // matters: - // 1. Write the translated polygon to `scene`. The dispatcher's - // snapshot-diff right after `canCommit` returns will pick - // this up as the single tracked change for undo. - // 2. `markDirty` directly — bypasses the rAF-deferred batch in - // `updateNodesAction`, so `GeometrySystem` sees the dirty - // flag synchronously and can rebuild this frame (without - // this the rebuild slides into the next frame and the slab - // visually pops to its original position for one paint). - // 3. Clear `useLiveTransforms` — `ParametricNodeRenderer` then - // re-renders `` instead of the - // live delta. Without the rebuild from step 2 also landing - // this frame, the group would render at (0,0,0) over the - // *unrebuilt* (still-original) geometry → original-position - // blink. With step 2 in place, the rebuild and the React - // render commit together → smooth. - useScene.getState().updateNodes([ - { - id: slabId, - data: { - polygon: translatePolygon(originalPolygon, dx, dz), - holes: originalHoles.map((h) => translatePolygon(h, dx, dz)), - }, - }, - ]) - useScene.getState().markDirty(slabId) - useLiveTransforms.getState().clear(slabId) - return true - }, - } - return session -} +export const slabFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => + createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 }) diff --git a/packages/nodes/src/stair/floorplan-move.ts b/packages/nodes/src/stair/floorplan-move.ts index a2f30edec..ef33a860a 100644 --- a/packages/nodes/src/stair/floorplan-move.ts +++ b/packages/nodes/src/stair/floorplan-move.ts @@ -1,83 +1,56 @@ import { type AnyNodeId, + collectAlignmentAnchors, type FloorplanMoveTarget, type FloorplanMoveTargetSession, type StairNode, snapScalar, useScene, } from '@pascal-app/core' -import { getSegmentGridStep } from '@pascal-app/editor' +import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor' /** - * 2D floor-plan move handler for stair — kicks in when the user clicks - * "Move" on the stair action menu and the floor-plan view is active. + * 2D floor-plan move handler for stair. * - * **Delta-based motion, anchored on first pointermove.** The first - * `apply` only captures `rawAnchor` — the cursor's pointer position the - * instant the move begins — and skips writing to the scene. Subsequent - * applies translate the stair's original position by the cursor's raw - * delta and then snap the *absolute* result to the 0.5 m grid. + * **Pivot semantics.** The stair's ORIGIN (its `position`) follows the + * snapped cursor — the same pivot the 3D move tool (`shared/move-roof-tool`) + * uses: it positions the stair by its origin at the grid-snapped, aligned + * cursor, NOT by the grab offset under the mouse. This replaces the old + * grab-relative delta so dragging in 2D tracks the same point as 3D. * - * Anchoring matters because the action-menu Move button portals to - * `document.body`, so the move starts with the cursor wherever the menu - * sits (often nowhere near the stair). The previous "position = snapped - * cursor" implementation made the stair teleport to the menu's screen - * position on the very first pointermove, which is the "drag doesn't - * happen properly" symptom. Mirrors the same anchor pattern wall's - * `floorplan-move.ts` uses. + * Figma alignment is layered on the origin point (single anchor), matching + * `move-roof-tool`'s "align by origin" behaviour; Alt bypasses. Guides are + * cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown. * - * Snapping the absolute position (rather than the delta) keeps the - * stair on the same 0.5 m grid the 3D StairTool placement and 3D - * MoveRegistryNodeTool use — so dragging in 2D lands at the same - * grid intersections you'd hit dragging in 3D. - * - * Routing through `floorplanMoveTarget` (instead of the overlay's - * generic Path 2 translate) fixes two latent bugs the generic path - * has for stair: - * - * 1. Path 2's `onPointerUp` bails when `event.target.closest( - * '[data-floorplan-scene]')` fails — which happens when the - * pointer-up lands on empty grid background. Path 1 uses the - * overlay's bounding-rect check, which accepts any pointer - * inside the SVG viewport. - * 2. Path 2 commits via a single `updateNode` call that has no - * "self-owned commit" hook, so the overlay's diff path can - * silently revert when the final state matches the snapshot. - * The `commit()` below mirrors door's pattern: take ownership - * of the atomic write so the deterministic - * revert → resume → `session.commit()` path runs. + * The position is written straight to scene each tick (the stair has a real + * `position` field, unlike polygon kinds) and re-applied atomically via + * `commit()` so the overlay's deterministic revert → resume → commit path + * records a single undo step (same pattern door / window use). */ -export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { - // Capture the stair's original position once — apply() reads these - // every tick instead of re-querying scene state (which would - // double-apply our own writes). - const originalX = node.position[0] +export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => { const startY = node.position[1] - const originalZ = node.position[2] - - let rawAnchor: [number, number] | null = null + // Alignment candidates gathered once — the scene is stable during the drag. + const candidates = collectAlignmentAnchors(nodes, node.id) let lastValid: { position: [number, number, number] } | null = null const session: FloorplanMoveTargetSession = { affectedIds: [node.id as AnyNodeId], apply({ planPoint, modifiers }) { - if (!rawAnchor) { - rawAnchor = [planPoint[0], planPoint[1]] - return - } - const rawDx = planPoint[0] - rawAnchor[0] - const rawDz = planPoint[1] - rawAnchor[1] - // Snap the absolute new position to the editor's current grid - // step (the same one the cursor / draft snap to — driven by - // `useEditor.gridSnapStep`). Hardcoding 0.5 here caused the stair - // to snap to half-metre cells even when the user had set the grid - // to a finer step like 0.1, so the cursor and the stair SVG - // landed at different grid points. Shift bypasses snap entirely. + // Snap the origin to the editor's current grid step (driven by + // `useEditor.gridSnapStep`). Shift bypasses the grid snap. const step = getSegmentGridStep() - const rawX = originalX + rawDx - const rawZ = originalZ + rawDz - const sx = modifiers.shiftKey ? rawX : snapScalar(rawX, step) - const sz = modifiers.shiftKey ? rawZ : snapScalar(rawZ, step) + const gx = modifiers.shiftKey ? planPoint[0] : snapScalar(planPoint[0], step) + const gz = modifiers.shiftKey ? planPoint[1] : snapScalar(planPoint[1], step) + // Figma alignment on the origin point (Alt bypasses), matching the 3D + // move tool. Publishes guides via `useAlignmentGuides`. + const { point: aligned } = applyFloorplanAlignment( + [gx, gz], + [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }], + candidates, + { bypass: modifiers.altKey }, + ) + const sx = aligned[0] + const sz = aligned[1] if (lastValid && lastValid.position[0] === sx && lastValid.position[2] === sz) return lastValid = { position: [sx, startY, sz] } @@ -91,12 +64,8 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node }, commit() { // Own the atomic write so the overlay takes the deterministic - // commit-path (revert → resume → session.commit()). The dispatcher's - // diff path would otherwise re-derive the final state by comparing - // the post-apply scene to the snapshot — which produces an empty - // diff (and silent revert) when the committed move happens to have - // identical key/value pairs to the snapshot. Owning commit removes - // that foot-gun. Same pattern door / window use. + // commit-path (revert → resume → session.commit()). Same pattern + // door / window use. if (!lastValid) return useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }]) }, diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index 5c3c51987..8928b3763 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -6,11 +6,13 @@ import { getMaxWallCurveOffset, getWallChordFrame, normalizeWallCurveOffset, + useAlignmentGuides, useLiveNodeOverrides, useScene, type WallNode, } from '@pascal-app/core' import { + alignFloorplanDraftPoint, getSegmentGridStep, isSegmentLongEnough, snapScalarToGrid, @@ -190,9 +192,16 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { ignoreWallIds: [node.id], step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined, }) + // Figma-style alignment on the dragged corner — snaps it onto another + // object's edge / wall face and publishes a guide. The dragged wall + // and its linked siblings (which cascade with the corner) are excluded + // from the candidate pool. Alt is reserved for detach, NOT bypass. + const aligned = alignFloorplanDraftPoint(snapped, { + excludeIds: [node.id, ...linkedWalls.map((w) => w.id)], + }) as WallPlanPoint - const primaryStart: WallPlanPoint = endpoint === 'start' ? snapped : fixedPoint - const primaryEnd: WallPlanPoint = endpoint === 'end' ? snapped : fixedPoint + const primaryStart: WallPlanPoint = endpoint === 'start' ? aligned : fixedPoint + const primaryEnd: WallPlanPoint = endpoint === 'end' ? aligned : fixedPoint // ALT detaches: the linked walls keep their original endpoints, // and only the dragged wall moves. @@ -229,6 +238,9 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { } }, canCommit() { + // Pointer-up always runs canCommit — drop the alignment guide here + // so it doesn't linger after a commit / reject. + useAlignmentGuides.getState().clear() // The dragged wall must still be long enough at the preview // length — checked against `lastPrimary*`, not scene, because // scene holds baseline values until commit(). diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index cdd91eb89..0d294bf41 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -6,7 +6,7 @@ import { type WindowNode, } from '@pascal-app/core' import { snapToHalf } from '@pascal-app/editor' -import { findClosestWallInPlan } from '../shared/wall-attach-target' +import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target' import { clampToWall, hasWallChildOverlap } from './window-math' /** @@ -49,7 +49,19 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) if (!hit) return - const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX) + // Figma-style along-wall alignment first (edge-to-edge with other + // openings / wall ends), winning over the 0.5m grid snap; falls back + // to grid when nothing aligns. Alt bypasses; Shift drops the grid snap. + const neighborX = modifiers.altKey + ? null + : snapLocalXToNeighbors({ + wall: hit.wall, + localX: hit.localX, + width: node.width, + selfId: node.id as AnyNodeId, + nodes, + }) + const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) const { clampedX, clampedY } = clampToWall( hit.wall, snappedLocalX, diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index a1e44d2e8..7dfdc08d6 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -5,6 +5,7 @@ import { zoneMoveEdgeAffordance, zoneMoveVertexAffordance, } from './floorplan-affordances' +import { zoneFloorplanMoveTarget } from './floorplan-move' import { zoneParametrics } from './parametrics' import { ZoneNode } from './schema' @@ -46,6 +47,11 @@ export const zoneDefinition: NodeDefinition = { priority: 4, }, floorplan: buildZoneFloorplan, + // 2D body move — centroid-pivot polygon mover (same as slab / ceiling). + // Without this, zone fell through to the overlay's generic free-translate + // path, which committed a `position` field zone has no schema for, so the + // polygon never actually moved on drop. + floorplanMoveTarget: zoneFloorplanMoveTarget, // Polygon editor when selected — same three operations slabs / ceilings // expose. The shared factories key off `node.polygon`, optional // `node.holes` (absent on zones). See `floorplan-affordances.ts`. diff --git a/packages/nodes/src/zone/floorplan-move.ts b/packages/nodes/src/zone/floorplan-move.ts new file mode 100644 index 000000000..a5ccf313c --- /dev/null +++ b/packages/nodes/src/zone/floorplan-move.ts @@ -0,0 +1,15 @@ +import type { FloorplanMoveTarget, ZoneNode } from '@pascal-app/core' +import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move' + +/** + * 2D floor-plan move handler for zone. Delegates to the shared polygon + * centroid-pivot mover — the zone's centroid snaps to the (grid-snapped, + * Figma-aligned) cursor. Zone has no `holes`, which the helper handles. + * + * Previously zone had no move target and fell through to the overlay's + * generic free-translate path, which committed a `position` field zone + * doesn't have (the polygon never moved on commit). Routing through this + * polygon mover translates the actual vertices. `meshY = 0`. + */ +export const zoneFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => + createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 }) From 841b9508677194b3a26ea2955b75da28252728e9 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 4 Jun 2026 14:41:31 +0530 Subject: [PATCH 15/17] refactor(editor): address architecture review for floor-plan work - Move usePlacementPreview store from core to editor: placement ghosts are an editor/tool concern the read-only viewer never needs. Rewire the column tool (via the @pascal-app/editor public surface) and the editor-internal elevator tool + preview layer (relative imports). - FloorplanPlacementPreviewLayer: read scene lazily in ctx.resolve instead of bulk-reading the nodes map during render. - wiki/architecture/tools.md: refresh the stale useLiveTransforms-per-kind note to reflect item/shelf/column (world-plan) + slab/ceiling/zone (delta). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/index.ts | 1 - .../core/src/store/use-placement-preview.ts | 26 ---------------- .../floorplan-placement-preview-layer.tsx | 8 +++-- .../tools/elevator/elevator-tool.tsx | 2 +- packages/editor/src/index.tsx | 1 + .../editor/src/store/use-placement-preview.ts | 31 +++++++++++++++++++ packages/nodes/src/column/tool.tsx | 3 +- wiki/architecture/tools.md | 2 +- 8 files changed, 40 insertions(+), 34 deletions(-) delete mode 100644 packages/core/src/store/use-placement-preview.ts create mode 100644 packages/editor/src/store/use-placement-preview.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd59d740b..c0b8abb0e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -108,7 +108,6 @@ export { type LiveNodeOverrides, } from './store/use-live-node-overrides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' -export { default as usePlacementPreview } from './store/use-placement-preview' export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { diff --git a/packages/core/src/store/use-placement-preview.ts b/packages/core/src/store/use-placement-preview.ts deleted file mode 100644 index d76083e24..000000000 --- a/packages/core/src/store/use-placement-preview.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Ephemeral store for a placement tool's 2D floor-plan ghost. A registry -// placement tool (e.g. column) publishes a fully-positioned, transient -// preview node on each `grid:move`; the floor-plan placement-preview layer -// subscribes and renders the node's `def.floorplan` footprint as a faint -// ghost that follows the cursor. The 3D view already shows a translucent -// mesh preview, so this only feeds the 2D layer. Producers clear on commit, -// cancel, and unmount. - -import { create } from 'zustand' -import type { AnyNode } from '../schema/types' - -type PlacementPreviewState = { - /** Transient preview node, already positioned + rotated at the (snapped, - * aligned) cursor. `null` when no placement is active. */ - node: AnyNode | null - set(node: AnyNode | null): void - clear(): void -} - -const usePlacementPreview = create((set) => ({ - node: null, - set: (node) => set({ node }), - clear: () => set({ node: null }), -})) - -export default usePlacementPreview diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx index 60b9c5556..775b27fb5 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx @@ -6,10 +6,10 @@ import { type FloorplanGeometry, type GeometryContext, nodeRegistry, - usePlacementPreview, useScene, } from '@pascal-app/core' import { memo } from 'react' +import usePlacementPreview from '../../../store/use-placement-preview' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' /** @@ -34,9 +34,11 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr // Minimal, unselected context — preview never shows selection chrome // (move handles / resize arrows / hatch live behind `viewState.selected`). - const nodes = useScene.getState().nodes + // `resolve` reads the scene lazily (a builder rarely calls it for a ghost, + // and `parent: null` short-circuits the elevator's level walk) so the layer + // never subscribes to / bulk-reads the nodes map during render. const ctx = { - resolve: (id: AnyNodeId) => nodes[id], + resolve: (id: AnyNodeId) => useScene.getState().nodes[id], children: [], siblings: [], parent: null, diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 85009ebf8..450118016 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -8,13 +8,13 @@ import { type LevelNode, resolveAlignment, useAlignmentGuides, - usePlacementPreview, useScene, } from '@pascal-app/core' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support' import { sfxEmitter } from '../../../lib/sfx-bus' +import usePlacementPreview from '../../../store/use-placement-preview' import { CursorSphere } from '../shared/cursor-sphere' import { DEFAULT_ELEVATOR_CAB_HEIGHT, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 42f45dc81..046f9b5f4 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -230,5 +230,6 @@ export { type PaletteViewProps, usePaletteViewRegistry, } from './store/use-palette-view-registry' +export { default as usePlacementPreview } from './store/use-placement-preview' export { useUploadStore } from './store/use-upload' export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts' diff --git a/packages/editor/src/store/use-placement-preview.ts b/packages/editor/src/store/use-placement-preview.ts new file mode 100644 index 000000000..c012b7da5 --- /dev/null +++ b/packages/editor/src/store/use-placement-preview.ts @@ -0,0 +1,31 @@ +// Ephemeral store for a placement tool's 2D floor-plan ghost. A registry +// placement tool (e.g. column / elevator) publishes a fully-positioned, +// transient preview node on each `grid:move`; the floor-plan +// placement-preview layer subscribes and renders the node's `def.floorplan` +// footprint as a faint ghost that follows the cursor. The 3D view already +// shows a translucent mesh preview, so this only feeds the 2D layer. +// +// Editor-only: the read-only viewer route never places nodes. Lives here +// rather than in `core` for that reason; node-kind tools (e.g. column) reach +// it through the `@pascal-app/editor` public surface, the same way they +// already consume `triggerSFX`. Producers clear on commit, cancel, and +// unmount. + +import type { AnyNode } from '@pascal-app/core' +import { create } from 'zustand' + +type PlacementPreviewState = { + /** Transient preview node, already positioned + rotated at the (snapped, + * aligned) cursor. `null` when no placement is active. */ + node: AnyNode | null + set(node: AnyNode | null): void + clear(): void +} + +const usePlacementPreview = create((set) => ({ + node: null, + set: (node) => set({ node }), + clear: () => set({ node: null }), +})) + +export default usePlacementPreview diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 5c8c9a22f..73f5eaa2e 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -11,10 +11,9 @@ import { resolveAlignment, snapPointToGrid, useAlignmentGuides, - usePlacementPreview, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import type { Group } from 'three' diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 038e0dd60..0d7a648e6 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -111,7 +111,7 @@ The store name suggests a uniform contract; the writes in practice are not. Docu | `slab` / `ceiling` / `fence` / polygon-based movers | position **delta** (`[Δx, 0, Δz]`) | unused / 0 | | `column` / `roof` / `elevator` / `spawn` / single-position kinds | world plan | world Y | -Anything that subscribes to `useLiveTransforms` to inform 2D rendering needs to handle these frames explicitly. The `FloorplanRegistryLayer` currently narrows its override to `node.type === 'item'` and sets `parentId: null` on the effective node so the resolver treats the live position as world plan coords. Extending the override to other kinds requires either standardising the frame at the writer (preferred long-term) or per-kind handling in the consumer. +Anything that subscribes to `useLiveTransforms` to inform 2D rendering needs to handle these frames explicitly. The `FloorplanRegistryLayer` override currently branches by kind: `item` / `shelf` / `column` are treated as world-plan (it copies `live.position` onto the effective node and forces `parentId: null` so the resolver skips the parent-chain transform), while `slab` / `ceiling` / `zone` are treated as a polygon **delta** (it translates the polygon vertices by `live.position`). Each kind added to the live-drag path grows this consumer-side switch; the preferred long-term fix is to standardise the frame at the writer so the consumer stops branching by `node.type`. ## Wall-attached node rotations must be wall-local From 05a89b91a79b34528a0bce3e68fbf895915a42f8 Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 4 Jun 2026 17:46:27 +0000 Subject: [PATCH 16/17] fix(nodes): recreate window draft on wall:move when null after placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful click-to-place, the click handler deletes the transient draft and relies on the wall-rebuild → R3F pointer-enter cascade to create a fresh draft for the next placement. If that cascade doesn't fire synchronously (e.g. async geometry rebuild) the next wall:move receives a null draftRef and bails — requiring leave/re-enter to place again. Fix: in onWallMove, when draftRef.current is null but we're hovering a valid wall, recreate the draft immediately (same WindowNode.parse + createNode path as onWallEnter). This is idempotent: if wall:enter does fire first, destroyDraft() in onWallEnter cleans up cleanly. Preserves parity with door multi-place behaviour, matching #367's intent. --- packages/nodes/src/window/tool.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 7f1c67ec9..0850ff074 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -180,6 +180,26 @@ const WindowTool: React.FC = () => { const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) + // Draft may be null after a successful placement (the click handler + // deletes it and relies on the wall rebuild → pointer-enter cascade to + // recreate it). Recreate it here on the first subsequent move so the + // preview is ready for the next click without requiring a leave/enter. + if (!draftRef.current) { + const levelId = getLevelId() + if (levelId && event.node.parentId === levelId) { + const node = WindowNode.parse({ + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + wallId: event.node.id, + parentId: event.node.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, event.node.id as AnyNodeId) + draftRef.current = node + } + } + if (draftRef.current) { // Update the scene store on every move so the 2D floor plan // stays in sync (it re-renders from `node.position`). Only From d3ca8e24e574dc173b60bdb2b4b922e4e33ce6e3 Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 4 Jun 2026 17:56:08 +0000 Subject: [PATCH 17/17] fix(nodes): recreate door draft on wall:move when null after placement Mirror of the window fix one commit back: after click-to-place the DoorTool deletes its transient draft and relies on the wall-rebuild \u2192 R3F pointer-enter cascade to spawn a fresh draft for the next placement. When that cascade doesn't fire synchronously, the next wall:move sees a null draftRef and bails \u2014 forcing a leave/re-enter to place again. Recreate the draft in onWallMove when null and over a valid wall on the current level. Idempotent with onWallEnter (destroyDraft cleans up if both fire). --- packages/nodes/src/door/tool.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index a00ff1877..d50eff37a 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -172,6 +172,26 @@ const DoorTool: React.FC = () => { const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) + // Draft may be null after a successful placement (the click handler + // deletes it and relies on the wall rebuild → pointer-enter cascade to + // recreate it). Recreate it here on the first subsequent move so the + // preview is ready for the next click without requiring a leave/enter. + if (!draftRef.current) { + const levelId = getLevelId() + if (levelId && event.node.parentId === levelId) { + const node = DoorNode.parse({ + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + wallId: event.node.id, + parentId: event.node.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, event.node.id as AnyNodeId) + draftRef.current = node + } + } + if (draftRef.current) { // Update the scene store on every move so the 2D floor plan // stays in sync (it re-renders from `node.position`). Only