From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 1/8] 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 2/8] 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 06749a74c351dbe8544463c85e82dc4d2d5baeea Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 5 Jun 2026 15:33:37 +0530 Subject: [PATCH 3/8] editor: per-door-type floor-plan symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render a distinct, static plan symbol for each door type in the registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`), independent of the door's live open/close animation: - single / hinged: fixed 90° swing with a dashed quarter-circle arc - double / french: two mirrored half-width leaves + dashed arcs - folding / bifold: static zigzag accordion (~80% span) on the wall face - sliding: bypass — two overlapping panels on parallel tracks + arrow - pocket: thin white leaf, ~60% closed, sliding into the solid wall - barn: surface-mounted panel parked over the wall, dashed closed-ghost + slide arrow The swing arc is dashed in screen-pixel units (the renderer uses non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection / slideDirection as appropriate. Also includes pre-existing working-tree changes unrelated to the door symbols: group move/rotate transform and box-select tweaks, and a regenerated ifc-converter next-env.d.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ifc-converter/next-env.d.ts | 2 +- .../components/editor/group-move-handle.tsx | 33 +- .../components/editor/group-rotate-handle.tsx | 23 +- .../editor/group-transform-shared.ts | 24 +- .../tools/select/box-select-tool.tsx | 32 +- packages/nodes/src/door/floorplan.ts | 375 ++++++++++++++++-- 6 files changed, 413 insertions(+), 76 deletions(-) diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index 1ecf71a9d..03d9d8ea9 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -13,6 +13,7 @@ import { computeGroupBox, CORNER_OFFSET, expandToComponent, + levelFrame, type Vec2, } from './group-transform-shared' import { @@ -107,15 +108,18 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { const planeY = rest.baseY // Snapshot selected participants + connected wall/fence neighbours. - const { starts, links } = collectParticipants( - ids, - useScene.getState().nodes, - useViewer.getState().selection.levelId, - ) + const levelId = useViewer.getState().selection.levelId + const { starts, links } = collectParticipants(ids, useScene.getState().nodes, 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). + // Placements are stored in the level frame, so convert each world-space + // ground-plane hit into that frame before measuring the delta. Frozen at + // drag-start; `frameOrigin` lets us map the local delta back to world for + // the gizmo's own travel (it's portalled to the scene root). + const { matrix: frame, inverse: frameInv } = levelFrame(levelId) + const frameOrigin = new Vector3().applyMatrix4(frame) + + // Horizontal drag plane at the group's base. const plane = new Plane(new Vector3(0, 1, 0), -planeY) const ndc = new Vector2() const setNDC = (clientX: number, clientY: number) => { @@ -130,7 +134,7 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { raycaster.setFromCamera(ndc, camera) const hit = new Vector3() if (!raycaster.ray.intersectPlane(plane, hit)) return - const startHit = hit.clone() + const startLocal = hit.clone().applyMatrix4(frameInv) document.body.style.cursor = 'grabbing' sfxEmitter.emit('sfx:item-pick') @@ -149,9 +153,14 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { raycaster.setFromCamera(ndc, camera) const moveHit = new Vector3() if (!raycaster.ray.intersectPlane(plane, moveHit)) return + const moveLocal = moveHit.applyMatrix4(frameInv) 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 + const dx = snap + ? Math.round((moveLocal.x - startLocal.x) / step) * step + : moveLocal.x - startLocal.x + const dz = snap + ? Math.round((moveLocal.z - startLocal.z) / step) * step + : moveLocal.z - startLocal.z // Ticker on each grid-cell crossing, like single-item placement. if (snap && (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz)) { @@ -185,7 +194,9 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { useScene.getState().markDirty(l.id) } - setLiveDelta([dx, dz]) + // Gizmo rides the group in world space; map the level-frame delta back out. + const worldDelta = new Vector3(dx, 0, dz).applyMatrix4(frame).sub(frameOrigin) + setLiveDelta([worldDelta.x, worldDelta.z]) } const cleanup = () => { diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 8a0736b59..6d803663c 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -13,6 +13,7 @@ import { collectParticipants, computeGroupBox, expandToComponent, + levelFrame, type Vec2, type Vec3, } from './group-transform-shared' @@ -125,13 +126,17 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { // 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, - ) + const levelId = useViewer.getState().selection.levelId + const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId) if (starts.length === 0) return + // Placements live in the level frame; the world pivot must be converted into + // it before orbiting positions, or a rotated building displaces the centre. + // The swept angle is frame-invariant (both frames differ by a constant yaw, + // which cancels in `angleOf(move) - angleOf(start)`), so it's still measured + // in world against `center` — keeping the world-space guide overlay correct. + const localCenter = center.clone().applyMatrix4(levelFrame(levelId).inverse) + // 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) @@ -140,7 +145,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { // 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)) + spread = Math.max(spread, Math.hypot(x - localCenter.x, z - localCenter.z)) } for (const s of starts) { if (s.kind === 'endpoint') { @@ -192,9 +197,9 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { 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 dx = x - localCenter.x + const dz = z - localCenter.z + return [localCenter.x + dx * cos - dz * sin, localCenter.z + dx * sin + dz * cos] } const overrides = useLiveNodeOverrides.getState() for (const s of starts) { diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts index 868db0b20..350e8c57a 100644 --- a/packages/editor/src/components/editor/group-transform-shared.ts +++ b/packages/editor/src/components/editor/group-transform-shared.ts @@ -1,5 +1,5 @@ import { type AnyNode, type AnyNodeId, sceneRegistry } from '@pascal-app/core' -import { Box3 } from 'three' +import { Box3, Matrix4 } from 'three' // Shared plumbing for the group transform gizmos (rotate + move). Both operate // on the same multi-selection: classify each participant by how its placement @@ -168,10 +168,26 @@ export function expandToComponent( return Array.from(included) } +// Frozen world matrix of the level group + its inverse. A node's placement +// (`position` / `start` / `end`) is stored in its parent level's frame, but the +// gizmos raycast the ground plane in WORLD space. When the building is rotated +// those frames diverge, so a world-space drag delta / rotation pivot must be +// converted into the level frame before it's written back to placements — +// otherwise the move drifts off-axis from the cursor and the rotation orbits a +// displaced centre. Returns identity matrices when the level isn't mounted, which +// collapses to the old behaviour (world == local) for an unrotated building. +export function levelFrame(levelId: string | null): { matrix: Matrix4; inverse: Matrix4 } { + const obj = levelId ? sceneRegistry.nodes.get(levelId as AnyNodeId) : null + if (!obj) return { matrix: new Matrix4(), inverse: new Matrix4() } + obj.updateWorldMatrix(true, false) + const matrix = obj.matrixWorld.clone() + return { matrix, inverse: matrix.clone().invert() } +} + // 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. +// mounted yet. Used to place the gizmos (which are portalled to the scene root, +// so they live in world space); placement writes convert back to the level frame +// via `levelFrame`. export function computeGroupBox(ids: string[]): Box3 | null { const box = new Box3() const tmp = new Box3() 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 43a647b58..59ab750f2 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -211,12 +211,29 @@ function collectNodeIdsInBounds(bounds: Bounds | null): string[] { const result: string[] = [] + // Wall/fence segments and slab/ceiling/zone polygons are stored in the LEVEL's + // local frame, but `bounds` comes from a world-space ground-plane raycast. When + // the building is rotated those frames diverge, so map these coordinates to + // world before hit-testing — otherwise a wall is tested at its un-rotated + // (local) position and gets selected from where it used to be. (Items, columns, + // roofs, etc. already resolve true world coordinates via the scene registry.) + const levelObj = sceneRegistry.nodes.get(levelId) + if (levelObj) levelObj.updateWorldMatrix(true, false) + const levelMatrix = levelObj ? levelObj.matrixWorld : null + const _localToWorld = new Vector3() + const toWorldXZ = (x: number, z: number): [number, number] => { + if (!levelMatrix) return [x, z] + _localToWorld.set(x, 0, z).applyMatrix4(levelMatrix) + return [_localToWorld.x, _localToWorld.z] + } + if (phase === 'structure' && structureLayer === 'zones') { for (const childId of levelNode.children) { const node = nodes[childId as AnyNodeId] if (!node || node.type !== 'zone') continue const zone = node as ZoneNode - if (!bounds || polygonIntersectsBounds(zone.polygon, bounds)) { + const poly = zone.polygon.map(([x, z]) => toWorldXZ(x, z)) + if (!bounds || polygonIntersectsBounds(poly, bounds)) { result.push(zone.id) } } @@ -228,10 +245,9 @@ function collectNodeIdsInBounds(bounds: Bounds | null): string[] { if (node.type === 'wall' || node.type === 'fence') { const wall = node as WallNode - if ( - !bounds || - segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds) - ) { + const [sx, sz] = toWorldXZ(wall.start[0], wall.start[1]) + const [ex, ez] = toWorldXZ(wall.end[0], wall.end[1]) + if (!bounds || segmentIntersectsBounds(sx, sz, ex, ez, bounds)) { result.push(wall.id) } // Check wall children (doors/windows) @@ -253,12 +269,14 @@ function collectNodeIdsInBounds(bounds: Bounds | null): string[] { } } else if (node.type === 'slab') { const slab = node as SlabNode - if (!bounds || polygonIntersectsBounds(slab.polygon, bounds)) { + const poly = slab.polygon.map(([x, z]) => toWorldXZ(x, z)) + if (!bounds || polygonIntersectsBounds(poly, bounds)) { result.push(slab.id) } } else if (node.type === 'ceiling') { const ceiling = node as CeilingNode - if (!bounds || polygonIntersectsBounds(ceiling.polygon, bounds)) { + const poly = ceiling.polygon.map(([x, z]) => toWorldXZ(x, z)) + if (!bounds || polygonIntersectsBounds(poly, bounds)) { result.push(ceiling.id) } } else if (node.type === 'roof') { diff --git a/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts index 61ac13e32..548a25d3d 100644 --- a/packages/nodes/src/door/floorplan.ts +++ b/packages/nodes/src/door/floorplan.ts @@ -13,12 +13,22 @@ import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dim * * 1. The door footprint rectangle in the wall cutout (themed * accent stroke when selected). - * 2. The door swing arc — a quarter-circle from the hinge to the - * door's open position, modulated by `swingAngle`, `hingesSide`, - * and `swingDirection`. Renders as a wedge of low-opacity fill so - * the swept area reads at a glance. + * 2. The door swing arc — a fixed quarter-circle from the hinge to + * the door's fully-open (90°) position, oriented by `hingesSide` + * and `swingDirection`. The angle is intentionally constant so the + * plan symbol stays static regardless of the door's live open-close + * state. Renders as a wedge of low-opacity fill so the swept area + * reads at a glance. * 3. The door leaf — a thick line from the hinge to the open * position, terminating at the arc end. + * + * Double / french doors render two mirrored half-width leaves hinged at + * the opposite outer ends, each with its own dashed arc, meeting + * perpendicular at the centre — the standard double-door plan sign. + * + * Folding / bifold doors render a static zigzag accordion of panels + * across the opening (porting the 3D folding geometry's panel layout), + * with no swing arc. * 4. Center line through the cutout (matches the legacy's * `getOpeningCenterLine` segment for visual continuity). * @@ -65,7 +75,12 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp ? 'outward' : 'inward' : baseSwingDirection - const swingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0)) + // The floor-plan door symbol is the standard architectural sign: the + // leaf drawn at a fixed 90° open with its quarter-circle swing arc. + // It deliberately ignores `node.swingAngle` / the live open-close + // animation — the plan view documents how the door is hung, not how + // far it currently happens to be open, so it must stay static. + const swingAngle = Math.PI / 2 // Footprint rectangle in the cutout. const points: readonly FloorplanPoint[] = [ @@ -103,45 +118,42 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp }, ] - // Swing geometry. The hinge sits at one end of the door along the - // wall direction; the strike sits at the opposite end. The leaf - // rotates around the hinge by `swingAngle` toward the inward / - // outward side of the wall. - const hingeTangentSign = hingesSide === 'left' ? 1 : -1 + // Swing geometry. A leaf is drawn as a wedge fill + dashed swing arc + + // solid leaf line. `drawSwingLeaf` emits one leaf given its hinge, the + // closed-leaf vector (hinge → strike, whose length is the swing + // radius) and a signed swing angle. Single doors draw one leaf; double + // / french doors draw two mirrored half-width leaves meeting in the + // middle. const swingSign = swingDirection === 'inward' ? 1 : -1 - const hingeX = cx - dirX * halfWidth * hingeTangentSign - const hingeZ = cz - dirZ * halfWidth * hingeTangentSign - // Closed leaf vector points from hinge to strike (along the wall). - const closedLeafX = dirX * width * hingeTangentSign - const closedLeafZ = dirZ * width * hingeTangentSign - - if (swingAngle > 1e-3 && width > 1e-3) { - // Rotate the closed leaf vector by `swingAngle * swingSign * - // hingeTangentSign` around the hinge to get the open leaf tip. - const angle = swingAngle * swingSign * hingeTangentSign - const cos = Math.cos(angle) - const sin = Math.sin(angle) - const openLeafX = closedLeafX * cos - closedLeafZ * sin - const openLeafZ = closedLeafX * sin + closedLeafZ * cos - const tipX = hingeX + openLeafX - const tipZ = hingeZ + openLeafZ - - // Closed leaf tip — where the leaf would land if fully closed. - const closedTipX = hingeX + closedLeafX - const closedTipZ = hingeZ + closedLeafZ - - // Swing arc — a path from closed tip to open tip via an arc - // centered at the hinge. SVG's A command takes rx ry rotation - // large-arc-flag sweep-flag x y. Sweep flag flips based on the - // signed angle direction. - const sweepFlag = angle >= 0 ? 1 : 0 - const arcPath = `M ${closedTipX} ${closedTipZ} A ${width} ${width} 0 0 ${sweepFlag} ${tipX} ${tipZ}` - - // Swept wedge fill (light, low opacity) — gives the door a - // visible "this is the open zone" treatment. + + const drawSwingLeaf = ( + hX: number, + hZ: number, + closedX: number, + closedZ: number, + signedAngle: number, + ) => { + const radius = Math.sqrt(closedX * closedX + closedZ * closedZ) + if (radius < 1e-3) return + + // Rotate the closed leaf vector around the hinge to the open tip. + const cos = Math.cos(signedAngle) + const sin = Math.sin(signedAngle) + const tipX = hX + (closedX * cos - closedZ * sin) + const tipZ = hZ + (closedX * sin + closedZ * cos) + const closedTipX = hX + closedX + const closedTipZ = hZ + closedZ + + // Swing arc — from closed tip to open tip via an arc centered at the + // hinge. SVG `A`: rx ry rotation large-arc-flag sweep-flag x y. The + // sweep flag flips with the signed angle direction. + const sweepFlag = signedAngle >= 0 ? 1 : 0 + const arcPath = `M ${closedTipX} ${closedTipZ} A ${radius} ${radius} 0 0 ${sweepFlag} ${tipX} ${tipZ}` + + // Swept wedge fill (light, low opacity) — reads as the open zone. children.push({ kind: 'path', - d: `M ${hingeX} ${hingeZ} L ${closedTipX} ${closedTipZ} ${arcPath + d: `M ${hX} ${hZ} L ${closedTipX} ${closedTipZ} ${arcPath .replace(/^M [^A]+/, '') .trim()} Z`, fill: accentColor, @@ -149,7 +161,8 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp stroke: 'none', }) - // The arc itself, stroked. + // The arc itself, dashed to match the standard architectural plan + // symbol, where the door's swing path is drawn as a broken arc. children.push({ kind: 'path', d: arcPath, @@ -157,6 +170,9 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp stroke: accentColor, strokeWidth: showSelectedChrome ? 1.6 : 1.1, strokeOpacity: 0.85, + // Dash is in screen pixels because of `non-scaling-stroke` (same + // reason strokeWidth is a small pixel value, not metres). + strokeDasharray: '5 4', vectorEffect: 'non-scaling-stroke', strokeLinecap: 'round', }) @@ -164,8 +180,8 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp // The door leaf — line from hinge to the open tip. children.push({ kind: 'line', - x1: hingeX, - y1: hingeZ, + x1: hX, + y1: hZ, x2: tipX, y2: tipZ, stroke: accentColor, @@ -175,6 +191,277 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp }) } + const isDoubleLeaf = node.doorType === 'double' || node.doorType === 'french' + const isFolding = node.doorType === 'folding' + const isSliding = node.doorType === 'sliding' + const isPocket = node.doorType === 'pocket' + const isBarn = node.doorType === 'barn' + + if (isFolding && width > 1e-3) { + // Folding / bifold door: a static accordion of panels drawn as a + // zigzag that folds toward the hinge side, occupying ~70% of the + // opening (a real folding door stacks to one side, so it never + // spans the full width). Mirrors the 3D folding geometry's + // alternating-fold panels (`panelCount = leafCount === 2 ? 2 : 4`). + // The fold span / depth are fixed so the plan symbol stays static + // regardless of the live open state. + const FOLD_SPAN_RATIO = 0.8 + const panelCount = node.leafCount === 2 ? 2 : 4 + const panelLen = (width * FOLD_SPAN_RATIO) / panelCount + const peakDepth = panelLen * 0.7 + // Anchor the accordion's base line on the room-facing wall face (the + // edge / corner of the opening box) rather than the wall centreline, + // so the panels start from the box corner instead of its middle. + const baseOffX = perpX * halfDepth * swingSign + const baseOffZ = perpZ * halfDepth * swingSign + // Start at the hinge edge and step toward the opposite jamb. + const hingeTangentSign = hingesSide === 'left' ? 1 : -1 + const startX = cx - dirX * halfWidth * hingeTangentSign + baseOffX + const startZ = cz - dirZ * halfWidth * hingeTangentSign + baseOffZ + const stepX = dirX * panelLen * hingeTangentSign + const stepZ = dirZ * panelLen * hingeTangentSign + const peakX = perpX * peakDepth * swingSign + const peakZ = perpZ * peakDepth * swingSign + let d = '' + for (let i = 0; i <= panelCount; i++) { + const alongX = startX + stepX * i + const alongZ = startZ + stepZ * i + const isPeak = i % 2 === 1 + const px = alongX + (isPeak ? peakX : 0) + const pz = alongZ + (isPeak ? peakZ : 0) + d += `${i === 0 ? 'M' : 'L'} ${px} ${pz} ` + } + children.push({ + kind: 'path', + d: d.trim(), + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 2.4 : 1.7, + strokeLinecap: 'round', + strokeLinejoin: 'round', + vectorEffect: 'non-scaling-stroke', + }) + } else if (isSliding && width > 1e-3) { + // Sliding (bypass) door: two panels on parallel tracks that slide + // past each other. Each spans a bit over half the opening and they + // overlap in the centre, drawn at slightly different depths (one + // toward each wall face). A slide arrow shows the direction. Static + // regardless of the live open state. + const slideSign = node.slideDirection === 'right' ? 1 : -1 + const panelHalfThick = Math.min(depth * 0.22, 0.03) + const panelHalfLen = width * 0.275 + const panelRect = (centerAlong: number, perpSign: number): [number, number][] => { + const rcx = cx + dirX * centerAlong + perpX * panelHalfThick * perpSign + const rcz = cz + dirZ * centerAlong + perpZ * panelHalfThick * perpSign + const aX = dirX * panelHalfLen + const aZ = dirZ * panelHalfLen + const tX = perpX * panelHalfThick + const tZ = perpZ * panelHalfThick + return [ + [rcx - aX + tX, rcz - aZ + tZ], + [rcx + aX + tX, rcz + aZ + tZ], + [rcx + aX - tX, rcz + aZ - tZ], + [rcx - aX - tX, rcz - aZ - tZ], + ] + } + const pushPanel = (points: [number, number][]) => + children.push({ + kind: 'polygon', + points, + fill: fillColor, + stroke: accentColor, + strokeWidth: showSelectedChrome ? 2 : 1.4, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + // Left panel toward one face, right panel toward the other; they + // overlap across the centre. + pushPanel(panelRect(-halfWidth + panelHalfLen, 1)) + pushPanel(panelRect(halfWidth - panelHalfLen, -1)) + // Slide-direction arrow, centred and pushed clear of the wall face + // so it doesn't overlap the wall or panels. + const arrowPerp = -(halfDepth + Math.max(panelHalfThick * 4, halfWidth * 0.22)) + const arX = cx + perpX * arrowPerp + const arZ = cz + perpZ * arrowPerp + const tipX = arX + dirX * halfWidth * 0.5 * slideSign + const tipZ = arZ + dirZ * halfWidth * 0.5 * slideSign + const tailX = arX - dirX * halfWidth * 0.5 * slideSign + const tailZ = arZ - dirZ * halfWidth * 0.5 * slideSign + const headLen = halfWidth * 0.18 + const headSpread = headLen * 0.6 + const backX = tipX - dirX * headLen * slideSign + const backZ = tipZ - dirZ * headLen * slideSign + children.push({ + kind: 'path', + d: + `M ${tailX} ${tailZ} L ${tipX} ${tipZ} ` + + `M ${backX + perpX * headSpread} ${backZ + perpZ * headSpread} L ${tipX} ${tipZ} ` + + `L ${backX - perpX * headSpread} ${backZ - perpZ * headSpread}`, + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.4 : 1, + strokeOpacity: 0.85, + strokeLinecap: 'round', + strokeLinejoin: 'round', + vectorEffect: 'non-scaling-stroke', + }) + } else if (isPocket && width > 1e-3) { + // Pocket door: the leaf slides into a cavity inside the wall. Shown + // open — the leaf is tucked into the wall on the `slideDirection` + // side, and the carved pocket slot is sized to match the leaf (no + // empty cavity sticking out past it). The opening itself reads as a + // clear doorway. Static regardless of the live open state. + const slideSign = node.slideDirection === 'right' ? 1 : -1 + const leafHalfThick = Math.min(depth * 0.2, 0.025) + const rectPoints = ( + centerAlong: number, + halfLen: number, + halfThick: number, + ): [number, number][] => { + const rcx = cx + dirX * centerAlong + const rcz = cz + dirZ * centerAlong + const aX = dirX * halfLen + const aZ = dirZ * halfLen + const tX = perpX * halfThick + const tZ = perpZ * halfThick + return [ + [rcx - aX + tX, rcz - aZ + tZ], + [rcx + aX + tX, rcz + aZ + tZ], + [rcx + aX - tX, rcz + aZ - tZ], + [rcx - aX - tX, rcz - aZ - tZ], + ] + } + // Show the door ~60% closed: the leaf covers 60% of the opening and + // has slid 40% of its width into the pocket. The pocket side of the + // wall stays solid (no carve) so the in-wall part of the leaf reads + // as an outline over it, matching the standard plan symbol. + const CLOSE_FRACTION = 0.6 + const openOffset = (1 - CLOSE_FRACTION) * width + const leafCenter = slideSign * openOffset + // The leaf is a thin white rectangle with an outline — white-filled + // along its whole length, so the part sliding into the wall carves + // its shape out in white rather than showing the solid wall behind. + children.push({ + kind: 'polygon', + points: rectPoints(leafCenter, halfWidth, leafHalfThick), + fill: '#ffffff', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 2 : 1.4, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + } else if (isBarn && width > 1e-3) { + // Barn / surface-sliding door: the leaf rides a surface-mounted + // track in front of the wall. Shown as a solid panel parked over the + // wall on the slide side, a dashed ghost of its closed position over + // the opening, and a slide-direction arrow. `slideDirection` picks + // the side. Static regardless of the live open state. + const slideSign = node.slideDirection === 'right' ? 1 : -1 + const panelHalfThick = Math.min(depth * 0.3, 0.04) + // Sit the panels clearly in front of the wall face with a gap, so + // they read as surface-mounted rather than embedded in the wall. + const gap = Math.max(panelHalfThick * 1.6, halfDepth * 0.6) + const faceOffset = halfDepth + gap + panelHalfThick + const frontRect = (centerAlong: number, halfLen: number): [number, number][] => { + const fcx = cx + perpX * faceOffset + dirX * centerAlong + const fcz = cz + perpZ * faceOffset + dirZ * centerAlong + const aX = dirX * halfLen + const aZ = dirZ * halfLen + const tX = perpX * panelHalfThick + const tZ = perpZ * panelHalfThick + return [ + [fcx - aX + tX, fcz - aZ + tZ], + [fcx + aX + tX, fcz + aZ + tZ], + [fcx + aX - tX, fcz + aZ - tZ], + [fcx - aX - tX, fcz - aZ - tZ], + ] + } + // Dashed ghost of the closed position across the opening. + children.push({ + kind: 'polygon', + points: frontRect(0, halfWidth), + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.4 : 1, + strokeDasharray: '5 4', + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + // Solid panel parked over the wall on the slide side. + children.push({ + kind: 'polygon', + points: frontRect(slideSign * width, halfWidth), + fill: accentColor, + fillOpacity: showSelectedChrome ? 0.25 : 0.18, + stroke: accentColor, + strokeWidth: showSelectedChrome ? 2 : 1.4, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + // Slide-direction arrow, pushed out beyond the panels so it clears + // them instead of sitting on top. + const arrowOffset = faceOffset + panelHalfThick + gap + const arX = cx + perpX * arrowOffset + const arZ = cz + perpZ * arrowOffset + const tipX = arX + dirX * halfWidth * 0.8 * slideSign + const tipZ = arZ + dirZ * halfWidth * 0.8 * slideSign + const tailX = arX - dirX * halfWidth * 0.8 * slideSign + const tailZ = arZ - dirZ * halfWidth * 0.8 * slideSign + const headLen = halfWidth * 0.18 + const headSpread = headLen * 0.6 + const backX = tipX - dirX * headLen * slideSign + const backZ = tipZ - dirZ * headLen * slideSign + children.push({ + kind: 'path', + d: + `M ${tailX} ${tailZ} L ${tipX} ${tipZ} ` + + `M ${backX + perpX * headSpread} ${backZ + perpZ * headSpread} L ${tipX} ${tipZ} ` + + `L ${backX - perpX * headSpread} ${backZ - perpZ * headSpread}`, + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.4 : 1, + strokeOpacity: 0.85, + strokeLinecap: 'round', + strokeLinejoin: 'round', + vectorEffect: 'non-scaling-stroke', + }) + } else if (swingAngle > 1e-3 && width > 1e-3) { + if (isDoubleLeaf) { + // Two half-width leaves hinged at the opposite outer ends, each + // swinging toward the centre. `hingesSide` is irrelevant for a + // symmetric double door; only `swingDirection` chooses the side. + const halfLeafX = dirX * halfWidth + const halfLeafZ = dirZ * halfWidth + // Leaf at the start edge: closed leaf points toward the centre. + drawSwingLeaf( + cx - halfLeafX, + cz - halfLeafZ, + halfLeafX, + halfLeafZ, + swingAngle * swingSign, + ) + // Leaf at the end edge: closed leaf points the other way, swung + // with the opposite sign so both meet perpendicular at the centre. + drawSwingLeaf( + cx + halfLeafX, + cz + halfLeafZ, + -halfLeafX, + -halfLeafZ, + -swingAngle * swingSign, + ) + } else { + // Single leaf hinged at one end, strike at the opposite end. + const hingeTangentSign = hingesSide === 'left' ? 1 : -1 + drawSwingLeaf( + cx - dirX * halfWidth * hingeTangentSign, + cz - dirZ * halfWidth * hingeTangentSign, + dirX * width * hingeTangentSign, + dirZ * width * hingeTangentSign, + swingAngle * swingSign * hingeTangentSign, + ) + } + } + // Move handle — orange dot at the door center. Only visible when // selected. Pointer-down on this triggers `setMovingNode(door)` // → `FloorplanRegistryMoveOverlay` → `def.floorplanMoveTarget`. From ed7681257b27bb655d3415bb47aca506ee06fc24 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 7 Jun 2026 10:50:57 +0530 Subject: [PATCH 4/8] Fix recessed ceiling fixtures and draw safety --- .claude/launch.json | 11 ++ packages/core/src/schema/nodes/item.ts | 5 + .../editor/src/components/editor/grid.tsx | 5 + .../systems/roof/roof-edit-system.tsx | 6 +- .../tools/item/placement-strategies.ts | 16 +- .../ui/item-catalog/catalog-items.tsx | 1 + .../editor/src/hooks/use-ceiling-events.ts | 175 ++++++++++++++++++ packages/nodes/src/ceiling/renderer.tsx | 21 ++- packages/nodes/src/door/floorplan.ts | 5 +- packages/nodes/src/item/panel.tsx | 2 +- packages/nodes/src/item/renderer.tsx | 2 +- packages/nodes/src/roof-segment/renderer.tsx | 12 +- packages/nodes/src/roof/renderer.tsx | 14 +- .../nodes/src/shared/placeholder-geometry.ts | 27 +++ packages/nodes/src/stair-segment/renderer.tsx | 12 +- packages/nodes/src/stair/renderer.tsx | 10 +- packages/nodes/src/wall/renderer.tsx | 21 +-- .../viewer/src/components/viewer/index.tsx | 58 ++++++ packages/viewer/src/lib/drawable-geometry.ts | 24 +++ .../viewer/src/lib/merged-outline-node.ts | 4 + .../src/systems/ceiling/ceiling-system.tsx | 95 +++++++++- .../viewer/src/systems/door/door-system.tsx | 16 ++ .../systems/item-light/item-light-system.tsx | 33 +++- .../viewer/src/systems/roof/roof-system.tsx | 9 +- .../viewer/src/systems/stair/stair-system.tsx | 12 +- 25 files changed, 512 insertions(+), 84 deletions(-) create mode 100644 .claude/launch.json create mode 100644 packages/editor/src/hooks/use-ceiling-events.ts create mode 100644 packages/nodes/src/shared/placeholder-geometry.ts create mode 100644 packages/viewer/src/lib/drawable-geometry.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..72b2c5ffd --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "editor", + "runtimeExecutable": "bun", + "runtimeArgs": ["run", "dev"], + "port": 3002 + } + ] +} diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index fa8a04549..e37c3dc10 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -98,6 +98,11 @@ const assetSchema = z.object({ src: AssetUrl, dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), + // Ceiling fixtures (e.g. recessed downlights) that embed *into* the ceiling + // rather than hang below it: the item seats flush with the ceiling plane + // (its body rising into the void above) and the ceiling is cut out around + // the item's footprint. Ignored unless `attachTo === 'ceiling'`. + recessed: z.boolean().optional(), tags: z.array(z.string()).optional(), // Function-axis tag slugs from the taxonomy. Drives the hierarchical // Items-tab browse: a tree node matches when any of its descendant slugs diff --git a/packages/editor/src/components/editor/grid.tsx b/packages/editor/src/components/editor/grid.tsx index 72cb80f06..d413bfa2f 100644 --- a/packages/editor/src/components/editor/grid.tsx +++ b/packages/editor/src/components/editor/grid.tsx @@ -7,6 +7,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { MathUtils, type Mesh, PlaneGeometry, Vector2, Vector3 } from 'three' import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' +import { useCeilingEvents } from '../../hooks/use-ceiling-events' import { useGridEvents } from '../../hooks/use-grid-events' export const Grid = ({ @@ -117,6 +118,10 @@ export const Grid = ({ // Use custom raycasting for grid events (independent of mesh events) useGridEvents(gridY) + // Same technique for ceiling-item placement: a math-plane raycast per ceiling, + // so commits don't depend on hitting the thin, single-sided `ceiling-grid` + // overlay mesh (which dropped clicks even with the green box showing). + useCeilingEvents() // Track the last world-space cursor hit. The reveal-fade shader reads // `positionLocal.xy` (vertex position on the un-transformed plane), and diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx index 8708b7e4f..094ca94bc 100644 --- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx +++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx @@ -13,7 +13,11 @@ import * as THREE from 'three' // recomputation per segment when the user actually wants it back. function makeEmptySegmentGeometry(): THREE.BufferGeometry { const g = new THREE.BufferGeometry() - g.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + // Three zero-vertices (one degenerate, invisible triangle), not an empty + // attribute: in accessory-reveal mode the segments-wrapper is shown, so these + // meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer + // slot 0 unbound and the draw is rejected, poisoning the command encoder. + g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) // Match the four material slots the roof-segment renderer's material // array expects (0=top, 1=side, 2=interior, 3=shingle). Without these // groups, mesh.material is a single-material lookup that mismatches diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 724821df7..21e095dc5 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -365,16 +365,19 @@ export const ceilingStrategy = { // use the ceiling hit's local position rather than world position. const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) - const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z)) + // Recessed fixtures seat flush with the ceiling plane (body rising into the + // void above); everything else hangs its full height below the ceiling. + const seatY = ctx.asset.recessed ? 0 : -itemHeight + const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z)) return { stateUpdate: { surface: 'ceiling', ceilingId: event.node.id }, nodeUpdate: { - position: [x, -itemHeight, z], + position: [x, seatY, z], parentId: event.node.id, }, cursorRotationY: 0, - gridPosition: [x, -itemHeight, z], + gridPosition: [x, seatY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], stopPropagation: true, } @@ -396,10 +399,13 @@ export const ceilingStrategy = { const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) - const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z)) + // Recessed fixtures seat flush with the ceiling plane (body rising into the + // void above); everything else hangs its full height below the ceiling. + const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight + const worldSnapped = event.object.localToWorld(new Vector3(x, seatY, z)) return { - gridPosition: [x, -itemHeight, z], + gridPosition: [x, seatY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: 0, nodeUpdate: null, diff --git a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx index 8c1963af5..d0a42e71a 100644 --- a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx +++ b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx @@ -628,6 +628,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ rotation: [0, 0, 0], scale: [1, 1, 1], attachTo: 'ceiling', + recessed: true, interactive: { controls: [ { kind: 'toggle' }, diff --git a/packages/editor/src/hooks/use-ceiling-events.ts b/packages/editor/src/hooks/use-ceiling-events.ts new file mode 100644 index 000000000..c6f9960b3 --- /dev/null +++ b/packages/editor/src/hooks/use-ceiling-events.ts @@ -0,0 +1,175 @@ +import { + type AnyNodeId, + type CeilingEvent, + type CeilingNode, + emitter, + resolveLevelId, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' +import useEditor from '../store/use-editor' + +const UP = new Vector3(0, 1, 0) + +/** Ray-casting point-in-polygon on ceiling-local [x, z] tuples. */ +function pointInPolygon(x: number, z: number, polygon: Array<[number, number]>): boolean { + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const a = polygon[i]! + const b = polygon[j]! + const intersects = + a[1] > z !== b[1] > z && x < ((b[0] - a[0]) * (z - a[1])) / (b[1] - a[1]) + a[0] + if (intersects) inside = !inside + } + return inside +} + +/** + * Reliable pointer events for placing ceiling-attached items. + * + * Mirrors {@link useGridEvents} (the floor path): rather than relying on the + * thin, single-sided `ceiling-grid` overlay mesh to be hit by R3F's raycaster — + * which misses from the "wrong" camera side, near polygon edges/holes, or while + * the overlay is mid-reveal, dropping the commit click even though the green box + * still shows — it intersects a math plane at each ceiling's height (double-sided, + * so it hits from above or below) and point-in-polygon tests the hit. Emits + * `ceiling:enter/move/leave/click` so both the placement coordinator and the 2D + * floor-plan item preview keep working. + * + * Active only while a ceiling-attached item is being placed or moved. The + * `ceiling-grid` mesh no longer emits these events (see `CeilingRenderer`), so + * this is the single, reliable source. + */ +export function useCeilingEvents() { + const { camera, gl } = useThree() + const raycaster = useRef(new Raycaster()) + const pointer = useRef(new Vector2()) + const plane = useRef(new Plane(UP.clone(), 0)) + const worldHit = useRef(new Vector3()) + const meshWorld = useRef(new Vector3()) + const hoveredRef = useRef(null) + + useEffect(() => { + const canvas = gl.domElement + + const isActive = (): boolean => { + const ed = useEditor.getState() + if (ed.selectedItem?.attachTo === 'ceiling') return true + const moving = ed.movingNode + return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling' + } + + type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 } + + // The ceiling on the active level whose plane the cursor ray crosses inside + // its polygon, nearest the camera. + const pick = (nativeEvent: PointerEvent | MouseEvent): Hit | null => { + const activeLevelId = useViewer.getState().selection.levelId + if (!activeLevelId) return null + + const rect = canvas.getBoundingClientRect() + pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1 + pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1 + raycaster.current.setFromCamera(pointer.current, camera) + + const nodes = useScene.getState().nodes + const ceilingIds = sceneRegistry.byType.ceiling + if (!ceilingIds) return null + + let best: Hit | null = null + let bestDist = Number.POSITIVE_INFINITY + + for (const id of ceilingIds) { + const node = nodes[id as AnyNodeId] as CeilingNode | undefined + if (!node || node.type !== 'ceiling' || node.polygon.length < 3) continue + if (resolveLevelId(node, nodes) !== activeLevelId) continue + const mesh = sceneRegistry.nodes.get(id) + if (!mesh) continue + + mesh.getWorldPosition(meshWorld.current) + plane.current.set(UP, -meshWorld.current.y) + if (!raycaster.current.ray.intersectPlane(plane.current, worldHit.current)) continue + + const local = mesh.worldToLocal(worldHit.current.clone()) + if (!pointInPolygon(local.x, local.z, node.polygon)) continue + + const dist = raycaster.current.ray.origin.distanceToSquared(worldHit.current) + if (dist < bestDist) { + bestDist = dist + best = { node, mesh, world: worldHit.current.clone(), local } + } + } + return best + } + + const buildEvent = (hit: Hit, nativeEvent: PointerEvent | MouseEvent): CeilingEvent => ({ + node: hit.node, + position: [hit.world.x, hit.world.y, hit.world.z], + localPosition: [hit.local.x, hit.local.y, hit.local.z], + normal: [0, 1, 0], + object: hit.mesh, + stopPropagation: () => {}, + nativeEvent: nativeEvent as never, + }) + + const emitLeave = (nativeEvent: PointerEvent | MouseEvent) => { + const id = hoveredRef.current + if (!id) return + hoveredRef.current = null + const node = useScene.getState().nodes[id as AnyNodeId] as CeilingNode | undefined + const mesh = sceneRegistry.nodes.get(id) + if (!node || !mesh) return + emitter.emit( + 'ceiling:leave', + buildEvent( + { node, mesh, world: meshWorld.current.clone(), local: new Vector3() }, + nativeEvent, + ), + ) + } + + const onMove = (e: PointerEvent) => { + if (!isActive()) { + emitLeave(e) + return + } + const hit = pick(e) + if (!hit) { + emitLeave(e) + return + } + const ev = buildEvent(hit, e) + if (hoveredRef.current !== hit.node.id) { + if (hoveredRef.current) emitLeave(e) + hoveredRef.current = hit.node.id + emitter.emit('ceiling:enter', ev) + } + emitter.emit('ceiling:move', ev) + } + + const onClick = (e: MouseEvent) => { + if (useViewer.getState().cameraDragging) return + if (e.button !== 0) return + if (!isActive()) return + const hit = pick(e) + if (!hit) return + emitter.emit('ceiling:click', buildEvent(hit, e)) + } + + const onPointerLeave = (e: PointerEvent) => emitLeave(e) + + canvas.addEventListener('pointermove', onMove) + canvas.addEventListener('click', onClick) + canvas.addEventListener('pointerleave', onPointerLeave) + + return () => { + canvas.removeEventListener('pointermove', onMove) + canvas.removeEventListener('click', onClick) + canvas.removeEventListener('pointerleave', onPointerLeave) + } + }, [camera, gl]) +} diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index cf624bd8b..4e0e9fe75 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -5,23 +5,21 @@ import { getMaterialPresetByRef, resolveMaterial, useRegistry, + useScene, } from '@pascal-app/core' import { createSurfaceRoleMaterial, NodeRenderer, resolveSurfaceColor, - useNodeEvents, useViewer, } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' -import { BufferGeometry, Float32BufferAttribute } from 'three' +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' +import { createPlaceholderGeometry } from '../shared/placeholder-geometry' function createEmptyGeometry() { - const geometry = new BufferGeometry() - geometry.setAttribute('position', new Float32BufferAttribute([], 3)) - return geometry + return createPlaceholderGeometry() } const gridScale = 5 @@ -69,7 +67,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const gridPlaceholderGeometry = useMemo(createEmptyGeometry, []) useRegistry(node.id, 'ceiling', ref) - const handlers = useNodeEvents(node, 'ceiling') + // Build the real geometry on mount instead of relying on a child item to + // mark us dirty (CeilingSystem only rebuilds dirty ceilings). Ceiling-hosted + // items are async GLB loads, so without this the ceiling holds its + // placeholder geometry until the first child finishes downloading — and a + // childless ceiling would never build at all. Mirrors WallRenderer / + // RoofRenderer. + useLayoutEffect(() => { + useScene.getState().markDirty(node.id) + }, [node.id]) const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) @@ -123,7 +129,6 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { geometry={gridPlaceholderGeometry} material={materials.topMaterial} name="ceiling-grid" - {...handlers} scale={0} visible={false} /> diff --git a/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts index 548a25d3d..4b882971f 100644 --- a/packages/nodes/src/door/floorplan.ts +++ b/packages/nodes/src/door/floorplan.ts @@ -196,6 +196,9 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp const isSliding = node.doorType === 'sliding' const isPocket = node.doorType === 'pocket' const isBarn = node.doorType === 'barn' + // Swing doors get the dashed swing arc; garage and any other types + // fall through to just the opening footprint (the earlier behaviour). + const isSwingDoor = node.doorType === 'hinged' || isDoubleLeaf if (isFolding && width > 1e-3) { // Folding / bifold door: a static accordion of panels drawn as a @@ -425,7 +428,7 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke', }) - } else if (swingAngle > 1e-3 && width > 1e-3) { + } else if (isSwingDoor && swingAngle > 1e-3 && width > 1e-3) { if (isDoubleLeaf) { // Two half-width leaves hinged at the opposite outer ends, each // swinging toward the centre. `hingesSide` is irrelevant for a diff --git a/packages/nodes/src/item/panel.tsx b/packages/nodes/src/item/panel.tsx index 3cd4e317a..d484410f8 100644 --- a/packages/nodes/src/item/panel.tsx +++ b/packages/nodes/src/item/panel.tsx @@ -54,7 +54,7 @@ export default function ItemPanel() { // frame regenerates its cutout geometry around the moved item. if (n.asset.attachTo === 'wall' && n.parentId) { requestAnimationFrame(() => { - useScene.getState().dirtyNodes.add(n.parentId as AnyNode['id']) + useScene.getState().markDirty(n.parentId as AnyNode['id']) }) } }, diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 37abb3607..5814a986f 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -156,7 +156,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { useEffect(() => { if (!node.parentId) return - useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) + useScene.getState().markDirty(node.parentId as AnyNodeId) }, [node.parentId]) useEffect(() => { diff --git a/packages/nodes/src/roof-segment/renderer.tsx b/packages/nodes/src/roof-segment/renderer.tsx index 69d58acd6..8c1b50492 100644 --- a/packages/nodes/src/roof-segment/renderer.tsx +++ b/packages/nodes/src/roof-segment/renderer.tsx @@ -20,6 +20,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { getRoofDebugMaterials, getRoofMaterials } from '../roof/roof-materials' +import { createPlaceholderGeometry } from '../shared/placeholder-geometry' export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const ref = useRef(null!) @@ -36,15 +37,8 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const parentNode = node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined - const placeholderGeometry = useMemo(() => { - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - geometry.addGroup(0, 0, 0) - geometry.addGroup(0, 0, 1) - geometry.addGroup(0, 0, 2) - geometry.addGroup(0, 0, 3) - return geometry - }, []) + // 4 groups map 1:1 to the roof's 4-material array (see getRoofMaterialArray). + const placeholderGeometry = useMemo(() => createPlaceholderGeometry(4), []) // Segment material precedence, per-role: // 1. Segment's role-specific override (topMaterial, edgeMaterial, wallMaterial). diff --git a/packages/nodes/src/roof/renderer.tsx b/packages/nodes/src/roof/renderer.tsx index a09878bd8..fc7b54846 100644 --- a/packages/nodes/src/roof/renderer.tsx +++ b/packages/nodes/src/roof/renderer.tsx @@ -10,8 +10,9 @@ import { } from '@pascal-app/core' import { getRoofMaterialArray, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import * as THREE from 'three' +import type * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' +import { createPlaceholderGeometry } from '../shared/placeholder-geometry' import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials' export const RoofRenderer = ({ node }: { node: RoofNode }) => { @@ -74,15 +75,8 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { }), ) - const placeholderGeometry = useMemo(() => { - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - geometry.addGroup(0, 0, 0) - geometry.addGroup(0, 0, 1) - geometry.addGroup(0, 0, 2) - geometry.addGroup(0, 0, 3) - return geometry - }, []) + // 4 groups map 1:1 to the roof's 4-material array (see getRoofMaterialArray). + const placeholderGeometry = useMemo(() => createPlaceholderGeometry(4), []) const customMaterial = useMemo( () => getRoofMaterialArray(node, shading, textures, colorPreset, sceneTheme), diff --git a/packages/nodes/src/shared/placeholder-geometry.ts b/packages/nodes/src/shared/placeholder-geometry.ts new file mode 100644 index 000000000..287a45af7 --- /dev/null +++ b/packages/nodes/src/shared/placeholder-geometry.ts @@ -0,0 +1,27 @@ +import { BufferGeometry, Float32BufferAttribute } from 'three' + +/** + * Placeholder geometry for a mesh whose real geometry is filled in later by a + * system (wall / roof / roof-segment / ceiling / stair-segment). These meshes + * are mounted *visible*, so the WebGPU renderer draws them on the first + * frame(s) before the owning system runs — and the system passes are + * rate-limited, so several meshes can still hold the placeholder across + * multiple frames. + * + * It carries three zero-vertices — a single degenerate, zero-area (invisible) + * triangle — rather than an empty `position` attribute. An empty attribute + * (count 0) makes three.js create no GPU buffer for it, so vertex buffer slot 0 + * is never bound and WebGPU rejects the draw with "Vertex buffer slot 0 … was + * not set", which poisons the whole command encoder (cascading into "Invalid + * CommandBuffer" on every queue submit). Three real vertices give it a bound + * buffer; the `groupCount` count-0 groups keep nothing drawn while matching the + * mesh's material-array length so raycasts / BVH never index past the materials. + */ +export function createPlaceholderGeometry(groupCount = 0): BufferGeometry { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3)) + for (let group = 0; group < groupCount; group++) { + geometry.addGroup(0, 0, group) + } + return geometry +} diff --git a/packages/nodes/src/stair-segment/renderer.tsx b/packages/nodes/src/stair-segment/renderer.tsx index 10c343b0f..5ad30897d 100644 --- a/packages/nodes/src/stair-segment/renderer.tsx +++ b/packages/nodes/src/stair-segment/renderer.tsx @@ -9,7 +9,8 @@ import { } from '@pascal-app/core' import { getStraightStairSegmentBodyMaterials, useNodeEvents, useViewer } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import * as THREE from 'three' +import type * as THREE from 'three' +import { createPlaceholderGeometry } from '../shared/placeholder-geometry' export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { const ref = useRef(null!) @@ -55,13 +56,8 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { parentNode, ]) - const placeholderGeometry = useMemo(() => { - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - geometry.addGroup(0, 0, 0) - geometry.addGroup(0, 0, 1) - return geometry - }, []) + // 2 groups map 1:1 to the stair segment's 2-material array (body + tread). + const placeholderGeometry = useMemo(() => createPlaceholderGeometry(2), []) useEffect(() => { return () => { diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx index 903ad3ed5..ec928e186 100644 --- a/packages/nodes/src/stair/renderer.tsx +++ b/packages/nodes/src/stair/renderer.tsx @@ -22,6 +22,7 @@ import { } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { createPlaceholderGeometry } from '../shared/placeholder-geometry' type SegmentTransform = { position: [number, number, number] @@ -106,13 +107,8 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { [node, shading, textures, colorPreset], ) - const straightPlaceholderGeometry = useMemo(() => { - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - geometry.addGroup(0, 0, 0) - geometry.addGroup(0, 0, 1) - return geometry - }, []) + // 2 groups map 1:1 to the stair body's 2-material array (body + tread). + const straightPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(2), []) useEffect(() => { return () => { diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 24abf9b5f..d2d9f152b 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -3,7 +3,8 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core' import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three' +import type { Mesh } from 'three' +import { createPlaceholderGeometry } from '../shared/placeholder-geometry' /** * Thin wall renderer. @@ -24,23 +25,11 @@ import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three' * That decision lands in a later milestone; for now the system retains * ownership of the rebuild loop. */ -function createEmptyWallGeometry(): BufferGeometry { - const geometry = new BufferGeometry() - geometry.setAttribute('position', new Float32BufferAttribute([], 3)) - geometry.addGroup(0, 0, 0) - geometry.addGroup(0, 0, 1) - geometry.addGroup(0, 0, 2) - return geometry -} - const WallRenderer = ({ node }: { node: WallNode }) => { const ref = useRef(null!) - const placeholderGeometry = useMemo(createEmptyWallGeometry, []) - const collisionPlaceholderGeometry = useMemo(() => { - const geometry = new BufferGeometry() - geometry.setAttribute('position', new Float32BufferAttribute([], 3)) - return geometry - }, []) + // 3 groups map 1:1 to the wall's 3-material array (see getVisibleWallMaterials). + const placeholderGeometry = useMemo(() => createPlaceholderGeometry(3), []) + const collisionPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(), []) useRegistry(node.id, 'wall', ref) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 0c0e69089..6409a1416 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -4,6 +4,7 @@ import { type AnyNodeId, StairOpeningSystem } from '@pascal-app/core' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import * as THREE from 'three/webgpu' +import { hasDrawableGeometry } from '../../lib/drawable-geometry' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' import { applyIsolation, clearIsolation } from '../../lib/isolation' import type { ColorPreset, RenderShading } from '../../lib/materials' @@ -44,6 +45,62 @@ extend(THREE as any) // renderers in parallel and only caching the second. const WEBGPU_RENDERER_CACHE = new WeakMap>() +const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet() + +/** + * Renderer-level safety net against the empty-vertex-buffer crash. + * + * Wraps the per-object render function so any draw whose geometry has a count-0 + * `position` attribute is skipped instead of submitted. One such draw leaves + * WebGPU vertex buffer slot 0 unbound, which the validator rejects and which + * poisons the *whole* command encoder — so a single stray empty mesh (e.g. a + * transient placeholder, or a derived edge/outline geometry) flickers the entire + * canvas, not just itself. See `hasDrawableGeometry`. + * + * The custom render-object function is the documented three.js hook for this + * (`Renderer.setRenderObjectFunction`); it must call `renderObject()` for + * everything it keeps. `MergedOutlineNode` captures and restores this function + * around its passes, so the guard survives outline rendering (its own passes + * carry the same check inline). + */ +function installEmptyDrawGuard(renderer: THREE.WebGPURenderer) { + renderer.setRenderObjectFunction( + ( + object: any, + scene: any, + camera: any, + geometry: any, + material: any, + group: any, + lightsNode: any, + clippingContext: any, + passId: any, + ) => { + if (!hasDrawableGeometry(geometry)) { + if (warnedEmptyDraw && !warnedEmptyDraw.has(geometry ?? object)) { + warnedEmptyDraw.add(geometry ?? object) + console.warn( + '[viewer] skipped a draw with an empty position buffer (would poison the WebGPU command encoder)', + { name: object?.name, type: object?.type, material: material?.name }, + ) + } + return + } + ;(renderer as any).renderObject( + object, + scene, + camera, + geometry, + material, + group, + lightsNode, + clippingContext, + passId, + ) + }, + ) +} + /** * Monitors the WebGPU device for loss / uncaptured errors and logs them. * WebGPU device loss can happen when: @@ -245,6 +302,7 @@ const Viewer = forwardRef(function Viewer( useViewer.getState().sceneTheme, ).toneMappingExposure await renderer.init() + installEmptyDrawGuard(renderer) return renderer } catch (err) { // Drop the failed promise from the cache so a future Canvas diff --git a/packages/viewer/src/lib/drawable-geometry.ts b/packages/viewer/src/lib/drawable-geometry.ts new file mode 100644 index 000000000..d474923be --- /dev/null +++ b/packages/viewer/src/lib/drawable-geometry.ts @@ -0,0 +1,24 @@ +import type { BufferGeometry } from 'three' + +/** + * True when `geometry` has a bound, non-empty `position` attribute — i.e. it is + * safe to submit to the WebGPU renderer. + * + * A geometry whose `position` attribute has `count === 0` (or no `position` at + * all) leaves WebGPU **vertex buffer slot 0 unbound**. The validator rejects the + * draw with "Vertex buffer slot 0 … was not set", and — critically — that single + * rejected draw **poisons the entire command encoder**: every other draw in the + * frame (the whole scene + every editor overlay) is discarded on the next queue + * submit ("Invalid CommandBuffer"). The visible result is the whole canvas + * flickering/garbling, not just the offending mesh. + * + * Individual call-sites guard against *creating* empty geometry (see + * `createPlaceholderGeometry`, the ceiling/door degenerate fallbacks, etc.), but + * transient/derived geometries can still slip through. This predicate is the + * renderer-level safety net: skipping a count-0 draw is a no-op visually (it + * would draw nothing anyway) while keeping the command encoder healthy. + */ +export function hasDrawableGeometry(geometry: BufferGeometry | undefined | null): boolean { + const position = geometry?.attributes?.position + return Boolean(position && position.count > 0) +} diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts index e1bb5ff7a..4c621ffb8 100644 --- a/packages/viewer/src/lib/merged-outline-node.ts +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -49,6 +49,7 @@ import { SpriteNodeMaterial, TempNode, } from 'three/webgpu' +import { hasDrawableGeometry } from './drawable-geometry' const _quadMesh = new QuadMesh() const _size = new Vector2() @@ -353,6 +354,7 @@ export class MergedOutlineNode extends TempNode { renderer.setRenderTarget(this._depthRT) renderer.setRenderObjectFunction( (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => { + if (!hasDrawableGeometry(geo)) return const inCache = this._cacheA.has(obj) || this._cacheB.has(obj) if (!inCache) { const m = obj.isSprite ? this._depthSpriteMaterial : this._depthMaterial @@ -368,6 +370,7 @@ export class MergedOutlineNode extends TempNode { renderer.setRenderTarget(this._groupA.maskBuffer) renderer.setRenderObjectFunction( (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => { + if (!hasDrawableGeometry(geo)) return if (this._cacheA.has(obj)) { const m = obj.isSprite ? this._prepareMaskSpriteMatA : this._prepareMaskMatA renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip) @@ -383,6 +386,7 @@ export class MergedOutlineNode extends TempNode { renderer.setRenderTarget(this._groupB.maskBuffer) renderer.setRenderObjectFunction( (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => { + if (!hasDrawableGeometry(geo)) return if (this._cacheB.has(obj)) { const m = obj.isSprite ? this._prepareMaskSpriteMatB : this._prepareMaskMatB renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip) diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 43a2aa24a..f45b7dac6 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -2,6 +2,8 @@ import { type AnyNodeId, type CeilingNode, getEffectiveNode, + getScaledDimensions, + type ItemNode, sceneRegistry, useScene, } from '@pascal-app/core' @@ -9,6 +11,13 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' +type SceneNodes = ReturnType['nodes'] + +// Recessed-fixture cutouts are inset slightly inside the item footprint so the +// fixture's trim (its widest part, sitting in the ceiling plane) overlaps the +// solid ceiling around the opening and hides the cut edge. +const RECESSED_HOLE_INSET = 0.82 + function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') if (!uv) return @@ -38,7 +47,9 @@ export const CeilingSystem = () => { // Merge any live drag override so the polygon / height resize // arrow rebuilds the mesh at pointer rate — zustand only learns // the final value on commit. Mirrors WallSystem / GeometrySystem. - updateCeilingGeometry(getEffectiveNode(node as CeilingNode), mesh) + const effective = getEffectiveNode(node as CeilingNode) + const itemHoles = collectRecessedItemHoles(effective, nodes) + updateCeilingGeometry(effective, mesh, itemHoles) clearDirty(id as AnyNodeId) } // If mesh not found, keep it dirty for next frame @@ -48,11 +59,62 @@ export const CeilingSystem = () => { return null } +/** + * Footprint cutouts for the ceiling's recessed child fixtures (e.g. recessed + * downlights). Each is a rotated rectangle in ceiling-local [x, z] space — the + * same space as `ceilingNode.polygon` — derived from the item's scaled + * dimensions and 2D position/yaw. Returned as extra holes so the cutout tracks + * the item automatically: adding, moving, or deleting a child re-dirties the + * ceiling (see node-actions / item renderer), which rebuilds this geometry. + */ +function collectRecessedItemHoles( + ceiling: CeilingNode, + nodes: SceneNodes, +): Array> { + const holes: Array> = [] + + for (const childId of ceiling.children ?? []) { + const child = nodes[childId] as ItemNode | undefined + if (!child || child.type !== 'item') continue + if (!child.asset.recessed || child.asset.attachTo !== 'ceiling') continue + + const [width, , depth] = getScaledDimensions(child) + const halfW = (width / 2) * RECESSED_HOLE_INSET + const halfD = (depth / 2) * RECESSED_HOLE_INSET + const cx = child.position[0] + const cz = child.position[2] + const yaw = child.rotation?.[1] ?? 0 + const cos = Math.cos(yaw) + const sin = Math.sin(yaw) + + // Corners of the (inset) footprint, rotated about Y and translated to the + // item's plan position. Y-rotation of (dx, dz): (dx·cos + dz·sin, -dx·sin + dz·cos). + const offsets: Array<[number, number]> = [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] + const corners: Array<[number, number]> = offsets.map(([dx, dz]) => [ + cx + dx * cos + dz * sin, + cz - dx * sin + dz * cos, + ]) + + holes.push(corners) + } + + return holes +} + /** * Updates the geometry for a single ceiling */ -function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) { - const newGeo = generateCeilingGeometry(node) +function updateCeilingGeometry( + node: CeilingNode, + mesh: THREE.Mesh, + extraHoles: Array> = [], +) { + const newGeo = generateCeilingGeometry(node, extraHoles) mesh.geometry.dispose() mesh.geometry = newGeo @@ -74,13 +136,28 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) { } /** - * Generates flat ceiling geometry from polygon (no extrusion) + * Generates flat ceiling geometry from polygon (no extrusion). + * + * `extraHoles` are transient, derived cutouts (e.g. recessed-fixture + * footprints) that are cut alongside the node's persisted `holes` but never + * stored on the node — they are recomputed on every rebuild. */ -export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferGeometry { +export function generateCeilingGeometry( + ceilingNode: CeilingNode, + extraHoles: Array> = [], +): THREE.BufferGeometry { const polygon = ceilingNode.polygon if (polygon.length < 3) { - return new THREE.BufferGeometry() + // A degenerate ceiling (fewer than 3 points, e.g. mid-edit) still gets a + // non-empty position buffer — three zero-vertices forming one invisible + // triangle. An empty attribute (count 0) would leave WebGPU vertex buffer + // slot 0 unbound when this mesh (and its cloned grid overlay) is drawn, + // which the validator rejects ("slot 0 … was not set") and which poisons + // the whole command encoder. + const degenerate = new THREE.BufferGeometry() + degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + return degenerate } // Create shape from polygon @@ -97,8 +174,10 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG } shape.closePath() - // Add holes to the shape - const holes = mergeSurfaceHolePolygons(ceilingNode.holes || []) + // Add holes to the shape: persisted structural openings (stair/elevator/ + // manual, merged to dissolve overlaps) plus transient recessed-fixture + // cutouts. Both are in the same ceiling-local [x, z] space. + const holes = [...mergeSurfaceHolePolygons(ceilingNode.holes || []), ...extraHoles] for (const holePolygon of holes) { if (holePolygon.length < 3) continue diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 9e3b1e923..8e9397595 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -2276,6 +2276,22 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { } syncDoorCutout(node, mesh) + + // Guard: some degenerate door configs can leave a child mesh with an + // empty (0-vertex) geometry — e.g. a zero-area extruded leaf frame. + // Submitting such a mesh trips a WebGPU error ("Vertex buffer slot 0 + // … was not set" on a Draw(0, …)). Hide any empty mesh so it is never + // drawn (it would render nothing anyway). + hideEmptyGeometryMeshes(mesh) +} + +function hideEmptyGeometryMeshes(root: THREE.Object3D) { + root.traverse((obj) => { + const child = obj as THREE.Mesh + if (!child.isMesh || !child.geometry) return + const position = child.geometry.getAttribute('position') + if (!position || position.count === 0) child.visible = false + }) } function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { diff --git a/packages/viewer/src/systems/item-light/item-light-system.tsx b/packages/viewer/src/systems/item-light/item-light-system.tsx index b0e2185a9..5a93d8f4e 100644 --- a/packages/viewer/src/systems/item-light/item-light-system.tsx +++ b/packages/viewer/src/systems/item-light/item-light-system.tsx @@ -35,6 +35,19 @@ const _itemPos = new Vector3() type SceneNodes = ReturnType['nodes'] type InteractiveState = ReturnType +function resolveNodeLevelId(nodeId: AnyNodeId, nodes: SceneNodes): string | null { + let current = nodes[nodeId] + let guard = 0 + + while (current && guard < 16) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined + guard += 1 + } + + return null +} + function scoreRegistration( reg: import('../../store/use-item-light-pool').LightRegistration, nodes: SceneNodes, @@ -67,8 +80,7 @@ function scoreRegistration( const dist = _camPos.distanceTo(_itemPos) / 200 // ── Level factor ────────────────────────────────────────────────────────── - const node = nodes[nodeId] - const itemLevelId = node?.parentId ?? null + const itemLevelId = resolveNodeLevelId(nodeId, nodes) let levelPenalty = 0 if (selectedLevelId) { @@ -135,7 +147,10 @@ export function ItemLightSystem() { scored.sort((a, b) => a.score - b.score) // Build the desired assignment (top POOL_SIZE keys) - const desired = scored.slice(0, POOL_SIZE).map((s) => s.key) + const desired = scored + .filter((s) => Number.isFinite(s.score)) + .slice(0, POOL_SIZE) + .map((s) => s.key) // Build a map of currently-assigned keys → slot index for hysteresis const currentlyAssigned = new Map() @@ -224,9 +239,11 @@ export function ItemLightSystem() { // Fade-out phase: lerp intensity → 0, then complete the transition if (slot.isFadingOut) { + light.visible = true light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) if (light.intensity < 0.01) { light.intensity = 0 + light.visible = false slot.isFadingOut = false slot.key = slot.pendingKey slot.pendingKey = null @@ -245,6 +262,7 @@ export function ItemLightSystem() { if (!slot.key) { // Idle slot — keep dark light.intensity = 0 + light.visible = false continue } @@ -252,6 +270,7 @@ export function ItemLightSystem() { if (!reg) { slot.key = null light.intensity = 0 + light.visible = false continue } @@ -275,7 +294,14 @@ export function ItemLightSystem() { ? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t) : reg.effect.intensityRange[0] + if (targetIntensity > 0) { + light.visible = true + } light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) + if (targetIntensity <= 0 && light.intensity < 0.01) { + light.intensity = 0 + light.visible = false + } } }) @@ -286,6 +312,7 @@ export function ItemLightSystem() { castShadow={false} intensity={0} key={i} + visible={false} ref={(el: any) => { lightRefs.current[i] = el }} diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index e2e690949..704b27e80 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -144,7 +144,14 @@ export const RoofSystem = () => { if (mesh.geometry.type === 'BoxGeometry') { mesh.geometry.dispose() const placeholder = new THREE.BufferGeometry() - placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + // Three zero-vertices (one degenerate, invisible triangle), not an + // empty attribute: an empty position (count 0) leaves WebGPU vertex + // buffer slot 0 unbound if the mesh is ever drawn, and computeBoundsTree + // needs a real position buffer to index. + placeholder.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array(9), 3), + ) computeGeometryBoundsTree(placeholder) mesh.geometry = placeholder } diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index 545df0457..fe98c9e0c 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -66,11 +66,9 @@ export const StairSystem = () => { } else if (isVisible) { return // Over budget — keep dirty, process next frame } else if (mesh.geometry.type === 'BoxGeometry') { - // Replace BoxGeometry placeholder with empty geometry + // Replace BoxGeometry placeholder with a non-drawing degenerate one. mesh.geometry.dispose() - const placeholder = new THREE.BufferGeometry() - placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - mesh.geometry = placeholder + mesh.geometry = createEmptyGeometry() } clearDirty(id as AnyNodeId) } else { @@ -566,7 +564,11 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] { function createEmptyGeometry(): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + // Three zero-vertices (one degenerate, invisible triangle), not an empty + // attribute: an empty position (count 0) leaves WebGPU vertex buffer slot 0 + // unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"), + // poisoning the command encoder. The count-0 groups keep nothing drawn. + geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX) geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX) return geometry From acd01ae08989e7a1832dcf2514aada30d4026535 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 7 Jun 2026 14:01:15 +0530 Subject: [PATCH 5/8] feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snap the wall draft / endpoint-move point onto existing wall geometry — corners, midpoints, wall–wall intersections, and along-wall edges — and show a beacon at the snap point whose glyph encodes what it caught (square = corner, triangle = midpoint, ✕ = intersection, circle = edge). - Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested). - Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon and a 2D SVG glyph beacon, both indigo to match the alignment guides. - Gated by a new persisted "Magnetic snap" toggle in the Display menu (useEditor); honored by draw + commit + endpoint-move in both views. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/editor/components/viewer-toolbar.tsx | 10 + packages/core/src/index.ts | 5 + .../core/src/store/use-wall-snap-indicator.ts | 32 +++ .../editor-2d/floorplan-snap-beacon-layer.tsx | 75 +++++ .../src/components/editor/floorplan-panel.tsx | 50 +++- .../editor/wall-snap-beacon-layer.tsx | 149 ++++++++++ .../src/components/tools/tool-manager.tsx | 3 + .../components/tools/wall/wall-drafting.ts | 183 ++++-------- .../tools/wall/wall-snap-geometry.test.ts | 79 +++++ .../tools/wall/wall-snap-geometry.ts | 270 ++++++++++++++++++ packages/editor/src/index.tsx | 3 + packages/editor/src/store/use-editor.tsx | 12 + .../nodes/src/wall/move-endpoint-tool.tsx | 20 +- packages/nodes/src/wall/tool.tsx | 35 ++- 14 files changed, 787 insertions(+), 139 deletions(-) create mode 100644 packages/core/src/store/use-wall-snap-indicator.ts create mode 100644 packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx create mode 100644 packages/editor/src/components/editor/wall-snap-beacon-layer.tsx create mode 100644 packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts create mode 100644 packages/editor/src/components/tools/wall/wall-snap-geometry.ts diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index e3b9e95e2..548c11efd 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -32,6 +32,7 @@ import { EyeOff, Footprints, Grid2X2, + Magnet, PenLine, SlidersHorizontal, Sparkles, @@ -270,6 +271,8 @@ function DisplayMenu() { const setEdges = useViewer((state) => state.setEdges) const shadows = useViewer((state) => state.shadows) const setShadows = useViewer((state) => state.setShadows) + const magneticSnap = useEditor((state) => state.magneticSnap) + const setMagneticSnap = useEditor((state) => state.setMagneticSnap) const activeShading = SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0] @@ -311,6 +314,13 @@ function DisplayMenu() { )} + keepOpen(e, () => setMagneticSnap(!magneticSnap))}> + + Magnetic snap + + {magneticSnap ? 'On' : 'Off'} + + keepOpen(e, () => setShadows(!shadows))}> Shadows diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 344eada88..6d9fde2c6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,6 +89,11 @@ export { resumeSceneHistory, } from './store/history-control' export { default as useAlignmentGuides } from './store/use-alignment-guides' +export { + type WallSnapKind, + type WallSnapPoint, + default as useWallSnapIndicator, +} from './store/use-wall-snap-indicator' export { type ControlValue, type DoorAnimationState, diff --git a/packages/core/src/store/use-wall-snap-indicator.ts b/packages/core/src/store/use-wall-snap-indicator.ts new file mode 100644 index 000000000..cd23e29cb --- /dev/null +++ b/packages/core/src/store/use-wall-snap-indicator.ts @@ -0,0 +1,32 @@ +// Ephemeral store for the "magnetic" wall-snap beacon shown during wall +// drafting. The wall tool writes the active snap point on pointermove when +// the draft endpoint locks onto existing wall geometry (a corner or a point +// along a wall); the 3D beacon layer subscribes and draws a vertical marker +// there. Cleared on commit, cancel, and unmount — same lifecycle as +// `use-alignment-guides`. + +import { create } from 'zustand' + +/** Which kind of wall geometry the draft point snapped to. */ +export type WallSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall' + +export type WallSnapPoint = { + /** Building-local plan coordinates (XZ meters). */ + x: number + z: number + kind: WallSnapKind +} + +type WallSnapIndicatorState = { + point: WallSnapPoint | null + set(point: WallSnapPoint | null): void + clear(): void +} + +const useWallSnapIndicator = create((set) => ({ + point: null, + set: (point) => set({ point }), + clear: () => set({ point: null }), +})) + +export default useWallSnapIndicator diff --git a/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx new file mode 100644 index 000000000..c923c3c86 --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx @@ -0,0 +1,75 @@ +'use client' + +import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/core' +import { memo } from 'react' +import { useFloorplanRender } from './floorplan-render-context' + +/** + * "Magnetic" wall-snap beacon for the 2D floor plan — the top-down twin of the + * 3D `WallSnapBeaconLayer`. Subscribes to the shared `useWallSnapIndicator` + * store (published by the floor-plan wall draft + endpoint-move handlers) and + * draws a marker at the snap point whose shape tells you *what* it caught, + * matching the 3D glyphs: + * + * endpoint (corner) → square midpoint → triangle + * intersection → ✕ cross wall body (edge) → circle + * + * Indigo to match the 3D beacon and stay distinct from the red Figma alignment + * guides. Sizes are pixel-budgeted via `unitsPerPixel` so the marker stays a + * constant size on screen at any zoom. Mounted inside the `data-floorplan-scene` + * group so coordinates are world meters (XZ) 1:1, like the alignment guides. + */ +const COLOR = '#6366f1' // indigo-500 — matches the 3D beacon + +export const FloorplanSnapBeaconLayer = memo(function FloorplanSnapBeaconLayer() { + const point = useWallSnapIndicator((s) => s.point) + const ctx = useFloorplanRender() + + if (!point) return null + + const upp = ctx?.unitsPerPixel ?? 0.01 + const m = 6 * upp // base half-size of the glyph in world meters + const stroke = 1.5 * upp + + return ( + + + + ) +}) + +function SnapMarker({ + color, + kind, + m, + stroke, + x, + z, +}: { + color: string + kind: WallSnapKind + m: number + stroke: number + x: number + z: number +}) { + if (kind === 'endpoint') { + return + } + if (kind === 'midpoint') { + const t = m * 1.3 + const points = `${x},${z - t} ${x - t},${z + t} ${x + t},${z + t}` + return + } + if (kind === 'intersection') { + const c = m * 1.4 + return ( + + + + + ) + } + // 'wall' (edge / along-wall) → circle + return +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index f2da8a022..c7c87a929 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -43,6 +43,7 @@ import { useLiveNodeOverrides, useLiveTransforms, useScene, + useWallSnapIndicator, type WallNode, type WindowNode, ZoneNode as ZoneNodeSchema, @@ -87,6 +88,7 @@ import { type FloorplanRenderContextValue, FloorplanRenderProvider, } from '../editor-2d/floorplan-render-context' +import { FloorplanSnapBeaconLayer } from '../editor-2d/floorplan-snap-beacon-layer' import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer' import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer' import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer' @@ -116,6 +118,7 @@ import { createWallOnCurrentLevel, isSegmentLongEnough, snapWallDraftPoint, + snapWallDraftPointDetailed, snapPointToGrid as snapWallPointToGrid, WALL_FINE_GRID_STEP, WALL_GRID_STEP, @@ -6374,6 +6377,7 @@ export function FloorplanPanel() { wallEndpointDragRef.current = null setWallEndpointDraft(null) setHoveredEndpointId(null) + useWallSnapIndicator.getState().clear() }, []) const clearWallCurveDrag = useCallback(() => { wallCurveDragRef.current = null @@ -6400,6 +6404,7 @@ export function FloorplanPanel() { // 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() + useWallSnapIndicator.getState().clear() }, [ clearFencePlacementDraft, clearCeilingPlacementDraft, @@ -6782,12 +6787,22 @@ export function FloorplanPanel() { // Wall endpoint move: grid snap only (no 45° angle snap from the // fixed corner — that's draft-only behaviour). Shift switches // to the fine grid step for precision. - const snappedPoint = snapWallDraftPoint({ + const snapResult = snapWallDraftPointDetailed({ point: planPoint, walls, ignoreWallIds: [dragState.wallId], step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, + magnetic: useEditor.getState().magneticSnap, }) + const snappedPoint = snapResult.point + // Magnetic beacon at the endpoint when it locked onto existing geometry. + useWallSnapIndicator + .getState() + .set( + snapResult.snap + ? { x: snappedPoint[0], z: snappedPoint[1], kind: snapResult.snap } + : null, + ) if (pointsEqual(dragState.currentPoint, snappedPoint)) { return @@ -7661,20 +7676,25 @@ export function FloorplanPanel() { // 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({ + const wallSnap = snapWallDraftPointDetailed({ point: planPoint, walls, step: shiftPressed ? WALL_FINE_GRID_STEP : undefined, + magnetic: useEditor.getState().magneticSnap, }) - const gridBase = snapWallPointToGrid(planPoint, gridStep) - const lockedToWall = wallSnapped[0] !== gridBase[0] || wallSnapped[1] !== gridBase[1] + const wallSnapped = wallSnap.point + // Locked onto existing geometry (corner / midpoint / crossing / edge) → + // that snap wins, so skip Figma alignment and stand the beacon there. + const lockedToWall = wallSnap.snap !== null let snappedPoint = wallSnapped if (lockedToWall) { useAlignmentGuides.getState().clear() } else { snappedPoint = alignFloorplanDraftPoint(wallSnapped, { bypass: event.altKey }) } + useWallSnapIndicator + .getState() + .set(wallSnap.snap ? { x: snappedPoint[0], z: snappedPoint[1], kind: wallSnap.snap } : null) // Emit `grid:move` so the registry-driven wall tool's 3D preview // tracks the cursor. The local draftEnd update below is what @@ -7926,6 +7946,19 @@ export function FloorplanPanel() { phase, toPoint2D, }) + // Wall-commit snap for the placement hook. Mirrors the move-preview branch: + // it honours the Magnetic snap toggle so a click never snaps to geometry the + // preview didn't (keeps 2D commit consistent with the preview and with 3D). + const snapWallDraftPointMagnetic = useCallback( + (args: { + point: WallPlanPoint + walls: WallNode[] + start?: WallPlanPoint + angleSnap?: boolean + step?: number + }) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }), + [], + ) const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({ activePolygonDraftPoints, ceilingDraftPoints, @@ -7959,7 +7992,7 @@ export function FloorplanPanel() { setRoofDraftStart, shiftPressed, snapPolygonDraftPoint, - snapWallDraftPoint, + snapWallDraftPoint: snapWallDraftPointMagnetic, toPoint2D, walls, }) @@ -9241,6 +9274,11 @@ export function FloorplanPanel() { paint on top of node geometry. */} + {/* "Magnetic" wall-snap beacon — per-kind glyph at the active + draft / endpoint-move snap point. Same store + coord space as + the alignment guides. */} + + s.point) + const levelId = useViewer((s) => s.selection.levelId) + const groupRef = useRef(null) + + // Track the active level's building-local Y each frame so the beacon stands + // on the floor being edited, not the building base — same source the + // alignment guide layer and `grid.tsx` read. + useFrame(() => { + const group = groupRef.current + if (!group) return + const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null + group.position.y = levelMesh ? levelMesh.position.y : 0 + }) + + if (!point) return null + return ( + + + + + ) +}) + +/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */ +function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) { + const y = FLOOR_LIFT + if (kind === 'endpoint') { + return ( + + ) + } + if (kind === 'midpoint') { + return ( + + ) + } + if (kind === 'intersection') { + // Two crossed bars → an ✕, the universal "crossing" glyph. + return ( + <> + + + + ) + } + // 'wall' (edge / along-wall) → circle + return ( + + ) +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 2a0cdee9f..dd8c4245c 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -10,6 +10,7 @@ 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 { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' @@ -273,6 +274,8 @@ export const ToolManager: React.FC = () => { tools above. Lives inside the building-local group so the building-local guide coords render at the right world position. */} + {/* "Magnetic" beacon at the active wall-draft snap point. */} + ) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 1fa496043..84e4084f4 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -3,10 +3,7 @@ import { type AnyNodeId, type DoorNode, getScaledDimensions, - getWallCurveFrameAt, - getWallCurveLength, type ItemNode, - isCurvedWall, useScene, type WallNode, WallNode as WallSchema, @@ -15,19 +12,30 @@ import { import { useViewer } from '@pascal-app/viewer' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' - -export type WallPlanPoint = [number, number] +import { + distanceSquared, + findWallSnapTarget, + findWallSpecialPointSnap, + projectPointOntoWall, + WALL_JOIN_SNAP_RADIUS, + type WallDraftSnapResult, + type WallPlanPoint, +} from './wall-snap-geometry' + +// The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so +// existing importers (fence drafting, the editor barrel) keep their paths. +export { + findWallSnapTarget, + WALL_JOIN_SNAP_RADIUS, + type WallDraftSnapKind, + type WallDraftSnapResult, + type WallPlanPoint, +} from './wall-snap-geometry' export const WALL_GRID_STEP = 0.5 // Smallest available grid snap. Used as a precision-mode step (Shift + // drag) so a drag can land on values the regular grid skips. export const WALL_FINE_GRID_STEP = 0.05 -export const WALL_JOIN_SNAP_RADIUS = 0.35 -// Generous radius for snapping to an *existing* wall's endpoint while -// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap -// is the strongest user intent (closing a polygon, attaching to a -// corner) and the cursor never lands pixel-perfect on a corner. -export const WALL_ENDPOINT_SNAP_RADIUS = 0.7 export const WALL_MIN_LENGTH = 0.01 const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4 @@ -43,12 +51,6 @@ type WallSplitIntersection = { point: WallPlanPoint } -function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number { - const dx = a[0] - b[0] - const dz = a[1] - b[1] - return dx * dx + dz * dz -} - export function getSegmentGridStep(): number { return useEditor.getState().gridSnapStep } @@ -83,24 +85,6 @@ export function getWallAngleSnapStep(step = getSegmentGridStep()): number { return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP } -function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null { - const [x1, z1] = wall.start - const [x2, z2] = wall.end - const dx = x2 - x1 - const dz = z2 - z1 - const lengthSquared = dx * dx + dz * dz - if (lengthSquared < 1e-9) { - return null - } - - const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared - if (t <= 0 || t >= 1) { - return null - } - - return [x1 + dx * t, z1 + dz * t] -} - function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] { const { id: _id, parentId: _parentId, children, ...rest } = wall @@ -332,84 +316,7 @@ function splitWallIfNeeded( } } -export function findWallSnapTarget( - point: WallPlanPoint, - walls: WallNode[], - options?: { ignoreWallIds?: string[]; radius?: number }, -): WallPlanPoint | null { - const ignoreWallIds = new Set(options?.ignoreWallIds ?? []) - const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2 - let bestTarget: WallPlanPoint | null = null - let bestDistanceSquared = Number.POSITIVE_INFINITY - - for (const wall of walls) { - if (ignoreWallIds.has(wall.id)) { - continue - } - - const candidates: Array = [wall.start, wall.end] - - if (isCurvedWall(wall)) { - const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) - for (let index = 0; index <= sampleCount; index += 1) { - const frame = getWallCurveFrameAt(wall, index / sampleCount) - candidates.push([frame.point.x, frame.point.y]) - } - } else { - candidates.push(projectPointOntoWall(point, wall)) - } - for (const candidate of candidates) { - if (!candidate) { - continue - } - - const candidateDistanceSquared = distanceSquared(point, candidate) - if ( - candidateDistanceSquared > radiusSquared || - candidateDistanceSquared >= bestDistanceSquared - ) { - continue - } - - bestTarget = candidate - bestDistanceSquared = candidateDistanceSquared - } - } - - return bestTarget -} - -/** - * Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a - * generous radius. Use this before `findWallSnapTarget` so the strong - * "attach to an existing wall corner" intent isn't accidentally pushed - * out of range by an interim grid snap that moved the cursor away from - * the endpoint. - */ -function findWallEndpointFromRaw( - point: WallPlanPoint, - walls: WallNode[], - ignoreWallIds?: string[], -): WallPlanPoint | null { - const ignored = new Set(ignoreWallIds ?? []) - const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2 - let best: WallPlanPoint | null = null - let bestDistSq = Number.POSITIVE_INFINITY - - for (const wall of walls) { - if (ignored.has(wall.id)) continue - for (const corner of [wall.start, wall.end] as WallPlanPoint[]) { - const d = distanceSquared(point, corner) - if (d <= radiusSquared && d < bestDistSq) { - best = corner - bestDistSq = d - } - } - } - return best -} - -export function snapWallDraftPoint(args: { +type SnapWallDraftArgs = { point: WallPlanPoint walls: WallNode[] start?: WallPlanPoint @@ -417,16 +324,33 @@ export function snapWallDraftPoint(args: { ignoreWallIds?: string[] /** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */ step?: number -}): WallPlanPoint { - const { point, walls, start, angleSnap = false, ignoreWallIds, step: overrideStep } = args + /** + * Magnetic snapping to existing wall geometry (corners, midpoints, + * crossings, wall bodies). When `false`, only grid/angle snap applies and + * `snap` is always `null`. Defaults to `true` so callers that don't care + * keep the prior behaviour. + */ + magnetic?: boolean +} - // Endpoint of an existing wall wins outright when the cursor is - // anywhere within `WALL_ENDPOINT_SNAP_RADIUS` — closing a polygon or - // attaching to a corner should "just work" without needing - // pixel-perfect aim. Done from the raw cursor so it isn't masked by - // an interim grid snap that nudged us out of range. - const endpointSnap = findWallEndpointFromRaw(point, walls, ignoreWallIds) - if (endpointSnap) return endpointSnap +export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult { + const { + point, + walls, + start, + angleSnap = false, + ignoreWallIds, + step: overrideStep, + magnetic = true, + } = args + + // Discrete special points (corner / midpoint / crossing) are taken from the + // raw cursor so an interim grid snap can't mask them. A corner always wins, + // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`. + if (magnetic) { + const special = findWallSpecialPointSnap(point, walls, ignoreWallIds) + if (special) return special + } const step = overrideStep ?? getSegmentGridStep() const angleStep = getWallAngleSnapStep(step) @@ -435,11 +359,16 @@ export function snapWallDraftPoint(args: { ? snapPointTo45Degrees(start, point, step, angleStep) : snapPointToGrid(point, step) - return ( - findWallSnapTarget(basePoint, walls, { - ignoreWallIds, - }) ?? basePoint - ) + if (magnetic) { + const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds }) + if (wallSnap) return { point: wallSnap, snap: 'wall' } + } + + return { point: basePoint, snap: null } +} + +export function snapWallDraftPoint(args: SnapWallDraftArgs): WallPlanPoint { + return snapWallDraftPointDetailed(args).point } export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean { diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts new file mode 100644 index 000000000..4aadb80d7 --- /dev/null +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' +import type { WallNode } from '@pascal-app/core' +import { + findWallSnapTarget, + findWallSpecialPointSnap, + type WallPlanPoint, +} from './wall-snap-geometry' + +function makeWall(start: WallPlanPoint, end: WallPlanPoint, id?: string): WallNode { + return { + object: 'node', + id: (id ?? `wall_${start.join('_')}_${end.join('_')}`) as WallNode['id'], + type: 'wall', + name: 'Wall', + parentId: null, + visible: true, + metadata: {}, + children: [], + start, + end, + thickness: 0.1, + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as WallNode +} + +describe('findWallSpecialPointSnap', () => { + test('snaps to a wall corner (endpoint) when near it', () => { + const walls = [makeWall([0, 0], [4, 0])] + const result = findWallSpecialPointSnap([0.1, 0.1], walls) + expect(result?.snap).toBe('endpoint') + expect(result?.point).toEqual([0, 0]) + }) + + test('snaps to a wall midpoint when near it (not a corner)', () => { + const walls = [makeWall([0, 0], [4, 0])] + const result = findWallSpecialPointSnap([2.1, 0.2], walls) + expect(result?.snap).toBe('midpoint') + expect(result?.point).toEqual([2, 0]) + }) + + test('snaps to the crossing of two walls (intersection)', () => { + // A runs along z=0; B crosses it at x=1. B's own midpoint is [1,1], far + // from the crossing, so the snap is the intersection, not a midpoint. + const walls = [makeWall([0, 0], [4, 0], 'a'), makeWall([1, -1], [1, 3], 'b')] + const result = findWallSpecialPointSnap([1.1, 0.1], walls) + expect(result?.snap).toBe('intersection') + expect(result?.point[0]).toBeCloseTo(1, 6) + expect(result?.point[1]).toBeCloseTo(0, 6) + }) + + test('corner wins over a midpoint when both are in range', () => { + const walls = [makeWall([0, 0], [1, 0])] + const result = findWallSpecialPointSnap([0.9, 0.1], walls) + expect(result?.snap).toBe('endpoint') + expect(result?.point).toEqual([1, 0]) + }) + + test('returns null when no special point is in range', () => { + const walls = [makeWall([0, 0], [4, 0])] + // Near the wall body but far from corner/midpoint — that's an edge snap, + // handled separately by findWallSnapTarget, not a special point. + expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull() + }) +}) + +describe('findWallSnapTarget (edge / along-wall)', () => { + test('projects onto a wall body within range', () => { + const walls = [makeWall([0, 0], [4, 0])] + const result = findWallSnapTarget([1.2, 0.1], walls) + expect(result?.[0]).toBeCloseTo(1.2, 6) + expect(result?.[1]).toBeCloseTo(0, 6) + }) + + test('returns null when too far from any wall', () => { + const walls = [makeWall([0, 0], [4, 0])] + expect(findWallSnapTarget([1.2, 2], walls)).toBeNull() + }) +}) diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts new file mode 100644 index 000000000..049e7a118 --- /dev/null +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -0,0 +1,270 @@ +// Pure geometry for "magnetic" wall-draft snapping — no store / viewer / React +// deps, so it's unit-testable in isolation. Coordinates are XZ plan points +// (building-local meters). `wall-drafting.ts` layers grid/angle snapping and +// scene access on top of these primitives. + +import { + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + type WallNode, +} from '@pascal-app/core' + +export type WallPlanPoint = [number, number] + +/** Which kind of existing-geometry snap produced a drafted point. */ +export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall' + +export type WallDraftSnapResult = { + point: WallPlanPoint + /** + * Set when `point` locked onto existing wall geometry (a corner, midpoint, + * crossing, or wall body) rather than a plain grid/angle position. This is + * the "magnetic" snap the beacon visualises; `null` for grid/angle-only. + */ + snap: WallDraftSnapKind | null +} + +export const WALL_JOIN_SNAP_RADIUS = 0.35 +// Generous radius for snapping to an *existing* wall's endpoint while +// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap +// is the strongest user intent (closing a polygon, attaching to a +// corner) and the cursor never lands pixel-perfect on a corner. +export const WALL_ENDPOINT_SNAP_RADIUS = 0.7 +// Discrete "special point" snaps taken from the raw cursor (like the +// endpoint snap) but slightly tighter — a corner is the strongest intent, +// a midpoint / crossing is the next tier down. +export const WALL_MIDPOINT_SNAP_RADIUS = 0.5 +export const WALL_INTERSECTION_SNAP_RADIUS = 0.5 + +export function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz +} + +export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null { + const [x1, z1] = wall.start + const [x2, z2] = wall.end + const dx = x2 - x1 + const dz = z2 - z1 + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-9) { + return null + } + + const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared + if (t <= 0 || t >= 1) { + return null + } + + return [x1 + dx * t, z1 + dz * t] +} + +export function findWallSnapTarget( + point: WallPlanPoint, + walls: WallNode[], + options?: { ignoreWallIds?: string[]; radius?: number }, +): WallPlanPoint | null { + const ignoreWallIds = new Set(options?.ignoreWallIds ?? []) + const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2 + let bestTarget: WallPlanPoint | null = null + let bestDistanceSquared = Number.POSITIVE_INFINITY + + for (const wall of walls) { + if (ignoreWallIds.has(wall.id)) { + continue + } + + const candidates: Array = [wall.start, wall.end] + + if (isCurvedWall(wall)) { + const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) + for (let index = 0; index <= sampleCount; index += 1) { + const frame = getWallCurveFrameAt(wall, index / sampleCount) + candidates.push([frame.point.x, frame.point.y]) + } + } else { + candidates.push(projectPointOntoWall(point, wall)) + } + for (const candidate of candidates) { + if (!candidate) { + continue + } + + const candidateDistanceSquared = distanceSquared(point, candidate) + if ( + candidateDistanceSquared > radiusSquared || + candidateDistanceSquared >= bestDistanceSquared + ) { + continue + } + + bestTarget = candidate + bestDistanceSquared = candidateDistanceSquared + } + } + + return bestTarget +} + +/** + * Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a + * generous radius. Use this before `findWallSnapTarget` so the strong + * "attach to an existing wall corner" intent isn't accidentally pushed + * out of range by an interim grid snap that moved the cursor away from + * the endpoint. + */ +export function findWallEndpointFromRaw( + point: WallPlanPoint, + walls: WallNode[], + ignoreWallIds?: string[], +): WallPlanPoint | null { + const ignored = new Set(ignoreWallIds ?? []) + const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2 + let best: WallPlanPoint | null = null + let bestDistSq = Number.POSITIVE_INFINITY + + for (const wall of walls) { + if (ignored.has(wall.id)) continue + for (const corner of [wall.start, wall.end] as WallPlanPoint[]) { + const d = distanceSquared(point, corner) + if (d <= radiusSquared && d < bestDistSq) { + best = corner + bestDistSq = d + } + } + } + return best +} + +/** Midpoint of a wall — curve midpoint for curved walls, segment midpoint otherwise. */ +function wallMidpoint(wall: WallNode): WallPlanPoint { + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, 0.5) + return [frame.point.x, frame.point.y] + } + return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] +} + +/** Nearest wall midpoint to the raw cursor, within `WALL_MIDPOINT_SNAP_RADIUS`. */ +export function findWallMidpointFromRaw( + point: WallPlanPoint, + walls: WallNode[], + ignoreWallIds?: string[], +): WallPlanPoint | null { + const ignored = new Set(ignoreWallIds ?? []) + const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2 + let best: WallPlanPoint | null = null + let bestDistSq = Number.POSITIVE_INFINITY + + for (const wall of walls) { + if (ignored.has(wall.id)) continue + const mid = wallMidpoint(wall) + const d = distanceSquared(point, mid) + if (d <= radiusSquared && d < bestDistSq) { + best = mid + bestDistSq = d + } + } + return best +} + +/** Crossing point of two straight segments, or null if they don't intersect within both. */ +function segmentIntersection( + a1: WallPlanPoint, + a2: WallPlanPoint, + b1: WallPlanPoint, + b2: WallPlanPoint, +): WallPlanPoint | null { + const rx = a2[0] - a1[0] + const rz = a2[1] - a1[1] + const sx = b2[0] - b1[0] + const sz = b2[1] - b1[1] + const denom = rx * sz - rz * sx + if (Math.abs(denom) < 1e-9) return null // parallel / collinear + + const qpx = b1[0] - a1[0] + const qpz = b1[1] - a1[1] + const t = (qpx * sz - qpz * sx) / denom + const u = (qpx * rz - qpz * rx) / denom + if (t < 0 || t > 1 || u < 0 || u > 1) return null + + return [a1[0] + t * rx, a1[1] + t * rz] +} + +/** + * Nearest point where two existing straight walls cross, within + * `WALL_INTERSECTION_SNAP_RADIUS`. Curved walls are skipped. O(n²) over the + * level's walls — fine at editor scale. + */ +export function findWallIntersectionFromRaw( + point: WallPlanPoint, + walls: WallNode[], + ignoreWallIds?: string[], +): WallPlanPoint | null { + const ignored = new Set(ignoreWallIds ?? []) + const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall)) + const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2 + let best: WallPlanPoint | null = null + let bestDistSq = Number.POSITIVE_INFINITY + + for (let i = 0; i < straight.length; i += 1) { + for (let j = i + 1; j < straight.length; j += 1) { + const crossing = segmentIntersection( + straight[i]!.start, + straight[i]!.end, + straight[j]!.start, + straight[j]!.end, + ) + if (!crossing) continue + const d = distanceSquared(point, crossing) + if (d <= radiusSquared && d < bestDistSq) { + best = crossing + bestDistSq = d + } + } + } + return best +} + +/** Pick the candidate nearest to `point`, ignoring nulls. */ +function nearestCandidate( + point: WallPlanPoint, + candidates: Array, +): WallDraftSnapResult | null { + let best: WallDraftSnapResult | null = null + let bestDistSq = Number.POSITIVE_INFINITY + for (const candidate of candidates) { + if (!candidate) continue + const d = distanceSquared(point, candidate.point) + if (d < bestDistSq) { + best = candidate + bestDistSq = d + } + } + return best +} + +/** + * Discrete "special point" snap from the raw cursor, in priority order: + * 1. corners (endpoints) — strongest intent, largest radius + * 2. midpoints / crossings — next tier; the nearer of the two wins + * A corner within range always wins over a midpoint/crossing. Returns null + * when no special point is in range (caller falls back to grid/edge snap). + */ +export function findWallSpecialPointSnap( + point: WallPlanPoint, + walls: WallNode[], + ignoreWallIds?: string[], +): WallDraftSnapResult | null { + const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds) + if (endpoint) return { point: endpoint, snap: 'endpoint' } + + const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds) + const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds) + return nearestCandidate(point, [ + midpoint && { point: midpoint, snap: 'midpoint' }, + intersection && { point: intersection, snap: 'intersection' }, + ]) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 046f9b5f4..2fec080fd 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -99,7 +99,10 @@ export { isSegmentLongEnough, snapPointToGrid, snapScalarToGrid, + type WallDraftSnapKind, + type WallDraftSnapResult, snapWallDraftPoint, + snapWallDraftPointDetailed, WALL_FINE_GRID_STEP, type WallPlanPoint, } from './components/tools/wall/wall-drafting' diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 0c61bc82d..bc17e228a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -326,6 +326,11 @@ type EditorState = { setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void gridSnapStep: GridSnapStep setGridSnapStep: (step: GridSnapStep) => void + // Magnetic snapping while drafting — snaps wall endpoints onto existing + // wall corners / wall bodies (the "magnetic" beacon). Independent of grid + // snap. On by default; toggled from the Display menu. + magneticSnap: boolean + setMagneticSnap: (enabled: boolean) => void showReferenceFloor: boolean toggleReferenceFloor: () => void setShowReferenceFloor: (show: boolean) => void @@ -368,6 +373,7 @@ type PersistedEditorLayoutState = Pick< | 'splitOrientation' | 'floorplanSelectionTool' | 'gridSnapStep' + | 'magneticSnap' | 'showReferenceFloor' | 'referenceFloorOffset' | 'referenceFloorOpacity' @@ -390,6 +396,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = splitOrientation: 'horizontal', floorplanSelectionTool: 'click', gridSnapStep: 0.5, + magneticSnap: true, showReferenceFloor: false, referenceFloorOffset: 1, referenceFloorOpacity: 0.35, @@ -502,6 +509,8 @@ function normalizePersistedEditorLayoutState( gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep) ? (state?.gridSnapStep as GridSnapStep) : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, + // Default on: only an explicit persisted `false` disables it. + magneticSnap: state?.magneticSnap !== false, showReferenceFloor: state?.showReferenceFloor === true, referenceFloorOffset: typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 @@ -842,6 +851,8 @@ const useEditor = create()( setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, setGridSnapStep: (step) => set({ gridSnapStep: step }), + magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap, + setMagneticSnap: (enabled) => set({ magneticSnap: enabled }), showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, toggleReferenceFloor: () => set((state) => ({ showReferenceFloor: !state.showReferenceFloor })), @@ -931,6 +942,7 @@ const useEditor = create()( splitOrientation: state.splitOrientation, floorplanSelectionTool: state.floorplanSelectionTool, gridSnapStep: state.gridSnapStep, + magneticSnap: state.magneticSnap, showReferenceFloor: state.showReferenceFloor, referenceFloorOffset: state.referenceFloorOffset, referenceFloorOpacity: state.referenceFloorOpacity, diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index e37ce12ec..e61c3244f 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -13,6 +13,7 @@ import { resumeSceneHistory, useAlignmentGuides, useScene, + useWallSnapIndicator, type WallNode, } from '@pascal-app/core' import { @@ -24,7 +25,7 @@ import { MeasurementPill, type MovingWallEndpoint, markToolCancelConsumed, - snapWallDraftPoint, + snapWallDraftPointDetailed, triggerSFX, useEditor, WALL_FINE_GRID_STEP, @@ -291,12 +292,14 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ // for precision placement, so it can land on positions the // active grid would skip (e.g. 0.05m increments when the active // grid is 0.5m). It does NOT bypass snap. - const snappedPoint = snapWallDraftPoint({ + const snapResult = snapWallDraftPointDetailed({ point: planPoint, walls: levelWalls, ignoreWallIds: [nodeId], step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined, + magnetic: useEditor.getState().magneticSnap, }) + const snappedPoint = snapResult.point // Figma-style alignment: nudge the dragged endpoint onto another wall / // fence endpoint or midpoint axis when within threshold, and publish a @@ -327,11 +330,22 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ previousGridPosRef.current = alignedPoint hasDraggedRef.current = true + // Stand the magnetic beacon at the endpoint when it locked onto existing + // wall geometry (corner / midpoint / crossing / wall body). + useWallSnapIndicator + .getState() + .set( + snapResult.snap + ? { x: alignedPoint[0], z: alignedPoint[1], kind: snapResult.snap } + : null, + ) + applyPreview(alignedPoint, event.nativeEvent.altKey) } const onPointerUp = () => { useAlignmentGuides.getState().clear() + useWallSnapIndicator.getState().clear() // Press-release without drag: dismiss the tool without committing. if (!hasDraggedRef.current) { useViewer.getState().setSelection({ selectedIds: [nodeId] }) @@ -379,6 +393,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ const onCancel = () => { useAlignmentGuides.getState().clear() + useWallSnapIndicator.getState().clear() restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) resumeSceneHistory(useScene) @@ -425,6 +440,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ return () => { useAlignmentGuides.getState().clear() + useWallSnapIndicator.getState().clear() if (!wasCommitted) { restoreOriginal(false) } diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 083ce0a29..78a7ce88c 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -9,6 +9,7 @@ import { resolveAlignment, useAlignmentGuides, useScene, + useWallSnapIndicator, type WallMiterData, type WallNode, } from '@pascal-app/core' @@ -22,7 +23,7 @@ import { getSegmentAngleReferenceAtPoint, markToolCancelConsumed, type SegmentAngleReference, - snapWallDraftPoint, + snapWallDraftPointDetailed, triggerSFX, useEditor, WALL_FINE_GRID_STEP, @@ -464,6 +465,7 @@ export const WallTool: React.FC = () => { } setDraftMeasurement(null) useAlignmentGuides.getState().clear() + useWallSnapIndicator.getState().clear() } const onGridMove = (event: GridEvent) => { @@ -478,7 +480,20 @@ export const WallTool: React.FC = () => { // a grid intersection. const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined const bypassAlign = event.nativeEvent?.altKey === true - gridPosition = alignPoint(snapWallDraftPoint({ point: localPoint, walls, step }), bypassAlign) + const snapResult = snapWallDraftPointDetailed({ + point: localPoint, + walls, + step, + magnetic: useEditor.getState().magneticSnap, + }) + gridPosition = alignPoint(snapResult.point, bypassAlign) + // Stand the magnetic beacon at the endpoint when it locked onto an + // existing wall corner / wall point; clear it for plain grid/angle moves. + useWallSnapIndicator + .getState() + .set( + snapResult.snap ? { x: gridPosition[0], z: gridPosition[1], kind: snapResult.snap } : null, + ) if (buildingState.current === 1) { const snappedLocal = gridPosition @@ -531,7 +546,12 @@ export const WallTool: React.FC = () => { if (buildingState.current === 0) { const snappedStart = alignPoint( - snapWallDraftPoint({ point: localClick, walls, step: clickStep }), + snapWallDraftPointDetailed({ + point: localClick, + walls, + step: clickStep, + magnetic: useEditor.getState().magneticSnap, + }).point, bypassAlign, ) gridPosition = snappedStart @@ -548,7 +568,12 @@ export const WallTool: React.FC = () => { setDraftMeasurement(null) } else if (buildingState.current === 1) { const snappedEnd = alignPoint( - snapWallDraftPoint({ point: localClick, walls, step: clickStep }), + snapWallDraftPointDetailed({ + point: localClick, + walls, + step: clickStep, + magnetic: useEditor.getState().magneticSnap, + }).point, bypassAlign, ) const dx = snappedEnd[0] - startingPoint.current.x @@ -565,6 +590,7 @@ export const WallTool: React.FC = () => { // for the next segment, and drop the just-shown guide. refreshAlignmentCandidates() useAlignmentGuides.getState().clear() + useWallSnapIndicator.getState().clear() const nextStart = createdWall.end startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) @@ -609,6 +635,7 @@ export const WallTool: React.FC = () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) useAlignmentGuides.getState().clear() + useWallSnapIndicator.getState().clear() } }, [unit]) From 4d1f8dc09cc052c2d522013de363088027c6973d Mon Sep 17 00:00:00 2001 From: sudhir Date: Sun, 7 Jun 2026 14:42:35 +0530 Subject: [PATCH 6/8] editor: garage and open-doorway floor-plan symbols Extend the per-door-type plan symbols in the registry floor-plan builder (packages/nodes/src/door/floorplan.ts): - open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel (mirrors the 3D system, which renders only the cutout for openings) - garage sectional: closed leaf + side tracks into the garage + dashed parked ghost at the inner end - garage roll-up: closed leaf + coil barrel (capsule) with a coil hint - garage tilt-up: closed leaf + dashed parked panel + dashed curved up-and-over swing path - gate the swing arc to actual swing doors (hinged/double/french) so other types fall back to the plain footprint Garage mechanisms sit on the interior (door-local -z) side to match the 3D garage builders, independent of swingDirection. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nodes/src/door/floorplan.ts | 231 ++++++++++++++++++++++++++- 1 file changed, 230 insertions(+), 1 deletion(-) diff --git a/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts index 4b882971f..646d0a12c 100644 --- a/packages/nodes/src/door/floorplan.ts +++ b/packages/nodes/src/door/floorplan.ts @@ -196,11 +196,20 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp const isSliding = node.doorType === 'sliding' const isPocket = node.doorType === 'pocket' const isBarn = node.doorType === 'barn' + const isGarageSectional = node.doorType === 'garage-sectional' + const isGarageRollup = node.doorType === 'garage-rollup' + const isGarageTiltup = node.doorType === 'garage-tiltup' // Swing doors get the dashed swing arc; garage and any other types // fall through to just the opening footprint (the earlier behaviour). const isSwingDoor = node.doorType === 'hinged' || isDoubleLeaf + // A frameless wall opening — drawn as a bare gap, no leaf / arc / panel + // (mirrors the 3D system, which renders only the cutout for openings). + const isOpening = node.openingKind === 'opening' - if (isFolding && width > 1e-3) { + if (isOpening) { + // Open doorway — a frameless gap in the wall. No leaf, arc, or panel; + // the cleared footprint above is the whole symbol. + } else if (isFolding && width > 1e-3) { // Folding / bifold door: a static accordion of panels drawn as a // zigzag that folds toward the hinge side, occupying ~70% of the // opening (a real folding door stacks to one side, so it never @@ -428,6 +437,226 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp strokeLinejoin: 'round', vectorEffect: 'non-scaling-stroke', }) + } else if (isGarageSectional && width > 1e-3) { + // Garage sectional door: an overhead door that rolls up on side + // tracks. Drawn as the closed leaf across the opening (just inside + // the interior face), two tracks running into the garage, and a + // dashed ghost of the door parked at the inner end of the tracks. + // `swingDirection` picks the interior side. Static. + // Mechanism (tracks / coil) sits on the interior side — matching the + // 3D garage builders, which place it on the door-local -z side. + const interiorSign = -swingSign + const panelHalfThick = Math.min(depth * 0.22, 0.03) + const trackLen = Math.max(width * 0.55, 0.6) + const aX = dirX * halfWidth + const aZ = dirZ * halfWidth + const tX = perpX * panelHalfThick + const tZ = perpZ * panelHalfThick + const leafRect = (perpDist: number): [number, number][] => { + const rcx = cx + perpX * perpDist + const rcz = cz + perpZ * perpDist + return [ + [rcx - aX + tX, rcz - aZ + tZ], + [rcx + aX + tX, rcz + aZ + tZ], + [rcx + aX - tX, rcz + aZ - tZ], + [rcx - aX - tX, rcz - aZ - tZ], + ] + } + const faceDist = interiorSign * halfDepth + const innerDist = interiorSign * (halfDepth + trackLen) + // Two side tracks running into the garage interior. + for (const edgeSign of [-1, 1]) { + const ex = cx + dirX * halfWidth * edgeSign + const ez = cz + dirZ * halfWidth * edgeSign + children.push({ + kind: 'line', + x1: ex + perpX * faceDist, + y1: ez + perpZ * faceDist, + x2: ex + perpX * innerDist, + y2: ez + perpZ * innerDist, + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.4 : 1, + strokeOpacity: 0.85, + strokeDasharray: '5 4', + vectorEffect: 'non-scaling-stroke', + strokeLinecap: 'round', + }) + } + // Dashed ghost of the door parked at the inner end of the tracks. + children.push({ + kind: 'polygon', + points: leafRect(innerDist - interiorSign * panelHalfThick), + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.4 : 1, + strokeDasharray: '5 4', + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + // Closed leaf, just inside the interior wall face. + children.push({ + kind: 'polygon', + points: leafRect(interiorSign * (halfDepth + panelHalfThick)), + fill: fillColor, + stroke: accentColor, + strokeWidth: showSelectedChrome ? 2 : 1.4, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + } else if (isGarageRollup && width > 1e-3) { + // Roll-up garage door: the curtain coils into a barrel just inside + // the opening (rather than running back on tracks like a sectional). + // Drawn as the closed leaf across the opening, the coil barrel — a + // capsule parallel to the wall — and a small coil hint at its centre. + // `swingDirection` picks the interior side. Static. + // Mechanism (tracks / coil) sits on the interior side — matching the + // 3D garage builders, which place it on the door-local -z side. + const interiorSign = -swingSign + const panelHalfThick = Math.min(depth * 0.22, 0.03) + const aX = dirX * halfWidth + const aZ = dirZ * halfWidth + const tX = perpX * panelHalfThick + const tZ = perpZ * panelHalfThick + // Closed leaf, just inside the interior wall face. + const leafPerp = interiorSign * (halfDepth + panelHalfThick) + const lcx = cx + perpX * leafPerp + const lcz = cz + perpZ * leafPerp + children.push({ + kind: 'polygon', + points: [ + [lcx - aX + tX, lcz - aZ + tZ], + [lcx + aX + tX, lcz + aZ + tZ], + [lcx + aX - tX, lcz + aZ - tZ], + [lcx - aX - tX, lcz - aZ - tZ], + ], + fill: fillColor, + stroke: accentColor, + strokeWidth: showSelectedChrome ? 2 : 1.4, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + // Coil barrel — a capsule (stadium) parallel to the wall, just inside + // the leaf. Built as a polygon so it's robust to wall orientation. + const drumRadius = Math.min(Math.max(width * 0.12, 0.08), halfWidth * 0.5, 0.16) + const drumPerp = interiorSign * (halfDepth + 2 * panelHalfThick + drumRadius) + const dcx = cx + perpX * drumPerp + const dcz = cz + perpZ * drumPerp + const capL = Math.max(halfWidth - drumRadius, 0) + const SAMPLES = 8 + const capsule: [number, number][] = [] + const c2x = dcx + dirX * capL + const c2z = dcz + dirZ * capL + for (let i = 0; i <= SAMPLES; i++) { + const th = Math.PI / 2 - (Math.PI * i) / SAMPLES + capsule.push([ + c2x + drumRadius * (Math.cos(th) * dirX + Math.sin(th) * perpX), + c2z + drumRadius * (Math.cos(th) * dirZ + Math.sin(th) * perpZ), + ]) + } + const c1x = dcx - dirX * capL + const c1z = dcz - dirZ * capL + for (let i = 0; i <= SAMPLES; i++) { + const ph = -Math.PI / 2 - (Math.PI * i) / SAMPLES + capsule.push([ + c1x + drumRadius * (Math.cos(ph) * dirX + Math.sin(ph) * perpX), + c1z + drumRadius * (Math.cos(ph) * dirZ + Math.sin(ph) * perpZ), + ]) + } + children.push({ + kind: 'polygon', + points: capsule, + fill: fillColor, + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.6 : 1.1, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + // Coil hint — a small circle at the barrel centre. + const innerR = drumRadius * 0.45 + const coil: [number, number][] = [] + for (let i = 0; i <= 12; i++) { + const a = (Math.PI * 2 * i) / 12 + coil.push([ + dcx + innerR * (Math.cos(a) * dirX + Math.sin(a) * perpX), + dcz + innerR * (Math.cos(a) * dirZ + Math.sin(a) * perpZ), + ]) + } + children.push({ + kind: 'polygon', + points: coil, + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.4 : 1, + strokeOpacity: 0.85, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + } else if (isGarageTiltup && width > 1e-3) { + // Tilt-up (up-and-over) garage door: one rigid panel that pivots at + // the top and swings up to park overhead inside the garage. Drawn as + // the closed leaf across the opening, a dashed panel parked into the + // interior, and a dashed curved swing path between them. + // `swingDirection` is ignored; like the other garage builders the + // mechanism is on the door-local -z (interior) side. Static. + const interiorSign = -swingSign + const panelHalfThick = Math.min(depth * 0.22, 0.03) + const projDepth = Math.max(width * 0.5, 0.7) + const aX = dirX * halfWidth + const aZ = dirZ * halfWidth + const tX = perpX * panelHalfThick + const tZ = perpZ * panelHalfThick + const leafRect = (perpDist: number): [number, number][] => { + const rcx = cx + perpX * perpDist + const rcz = cz + perpZ * perpDist + return [ + [rcx - aX + tX, rcz - aZ + tZ], + [rcx + aX + tX, rcz + aZ + tZ], + [rcx + aX - tX, rcz + aZ - tZ], + [rcx - aX - tX, rcz - aZ - tZ], + ] + } + const closedPerp = interiorSign * (halfDepth + panelHalfThick) + const parkedPerp = interiorSign * (halfDepth + projDepth) + // Dashed parked panel, projected overhead into the interior. + children.push({ + kind: 'polygon', + points: leafRect(parkedPerp), + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.4 : 1, + strokeDasharray: '5 4', + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) + // Dashed curved swing path from the closed leaf to the parked panel. + const startX = cx + perpX * closedPerp + const startZ = cz + perpZ * closedPerp + const endX = cx + perpX * parkedPerp + const endZ = cz + perpZ * parkedPerp + const midPerp = interiorSign * (halfDepth + projDepth * 0.5) + const ctrlX = cx + perpX * midPerp + dirX * projDepth * 0.5 + const ctrlZ = cz + perpZ * midPerp + dirZ * projDepth * 0.5 + children.push({ + kind: 'path', + d: `M ${startX} ${startZ} Q ${ctrlX} ${ctrlZ} ${endX} ${endZ}`, + fill: 'none', + stroke: accentColor, + strokeWidth: showSelectedChrome ? 1.4 : 1, + strokeOpacity: 0.85, + strokeDasharray: '5 4', + strokeLinecap: 'round', + vectorEffect: 'non-scaling-stroke', + }) + // Closed leaf, just inside the interior wall face. + children.push({ + kind: 'polygon', + points: leafRect(closedPerp), + fill: fillColor, + stroke: accentColor, + strokeWidth: showSelectedChrome ? 2 : 1.4, + vectorEffect: 'non-scaling-stroke', + strokeLinejoin: 'round', + }) } else if (isSwingDoor && swingAngle > 1e-3 && width > 1e-3) { if (isDoubleLeaf) { // Two half-width leaves hinged at the opposite outer ends, each From 34a24bb68134afe4e1be7b83520d3ea34ebb907e Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 8 Jun 2026 10:55:27 +0530 Subject: [PATCH 7/8] =?UTF-8?q?arch:=20enforce=20layer=20boundaries=20?= =?UTF-8?q?=E2=80=94=20registry=20dispatch,=20store=20relocation,=20shared?= =?UTF-8?q?=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three architectural fixes to bring the branch into full compliance: 1. **ceiling-system kind check → CeilingCutCapability** Replace `child.type === 'item'` branch in `ceiling-system` with registry dispatch. Add `CeilingCutCapability` type to `packages/core` registry types, implement `buildCeilingHole` on `itemDefinition`, and rewrite `collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through `nodeRegistry` — viewer never again inspects a node's kind directly. 2. **useAlignmentGuides + useWallSnapIndicator → packages/editor** These stores are editor-only UI (snap beacons, alignment guides). Move them from `packages/core/src/store/` to `packages/editor/src/store/`, re-export from `packages/editor`, and update all 34 consumer files across `packages/editor` and `packages/nodes` to import from `@pascal-app/editor`. 3. **findLevelAncestorId extracted to core** `item-light-system` had a private `resolveNodeLevelId` that duplicated level-ancestor traversal logic. Extract it as `findLevelAncestorId` in `packages/core` (spatial-grid-sync), export it, and replace the local copy. All four packages typecheck cleanly (zero errors). Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/spatial-grid/spatial-grid-sync.ts | 26 +++++++++ packages/core/src/index.ts | 7 +-- packages/core/src/registry/types.ts | 22 +++++++ .../floorplan-alignment-guide-layer.tsx | 2 +- .../floorplan-registry-move-overlay.tsx | 2 +- .../editor-2d/floorplan-snap-beacon-layer.tsx | 2 +- .../renderers/floorplan-registry-layer.tsx | 2 +- .../editor/alignment-3d-guide-layer.tsx | 3 +- .../src/components/editor/floorplan-panel.tsx | 3 +- .../editor/wall-snap-beacon-layer.tsx | 3 +- .../tools/elevator/elevator-tool.tsx | 2 +- .../tools/item/use-placement-coordinator.tsx | 2 +- .../registry/move-registry-node-tool.tsx | 2 +- .../src/components/tools/roof/roof-tool.tsx | 2 +- .../src/components/tools/stair/stair-tool.tsx | 2 +- packages/editor/src/index.tsx | 6 ++ .../src/lib/floorplan/apply-alignment.ts | 2 +- .../src/store/use-alignment-guides.ts | 2 +- .../src/store/use-wall-snap-indicator.ts | 0 packages/nodes/src/ceiling/move-tool.tsx | 3 +- packages/nodes/src/ceiling/tool.tsx | 2 +- packages/nodes/src/column/move-tool.tsx | 2 +- packages/nodes/src/column/tool.tsx | 3 +- packages/nodes/src/door/move-tool.tsx | 2 +- packages/nodes/src/door/tool.tsx | 2 +- .../nodes/src/fence/actions/move-endpoint.ts | 2 +- .../nodes/src/fence/floorplan-affordances.ts | 2 +- .../nodes/src/fence/move-endpoint-tool.tsx | 2 +- packages/nodes/src/fence/tool.tsx | 2 +- packages/nodes/src/item/definition.ts | 34 +++++++++++ packages/nodes/src/shared/move-roof-tool.tsx | 2 +- .../nodes/src/shared/polygon-centroid-move.ts | 3 +- .../src/shared/wall-opening-alignment.ts | 3 +- packages/nodes/src/shelf/tool.tsx | 3 +- packages/nodes/src/slab/move-tool.tsx | 2 +- packages/nodes/src/slab/tool.tsx | 2 +- .../nodes/src/wall/floorplan-affordances.ts | 2 +- .../nodes/src/wall/move-endpoint-tool.tsx | 4 +- packages/nodes/src/wall/tool.tsx | 4 +- packages/nodes/src/window/move-tool.tsx | 2 +- packages/nodes/src/window/tool.tsx | 2 +- .../src/systems/ceiling/ceiling-system.tsx | 58 +++++-------------- .../systems/item-light/item-light-system.tsx | 17 +----- 43 files changed, 146 insertions(+), 106 deletions(-) rename packages/{core => editor}/src/store/use-alignment-guides.ts (91%) rename packages/{core => editor}/src/store/use-wall-snap-indicator.ts (100%) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index ba669c218..6242fb390 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -28,6 +28,32 @@ export function resolveLevelId(node: AnyNode, nodes: Record): s return 'default' // fallback for orphaned items } +/** + * Walks the parent chain of `nodeId` and returns the id of the first ancestor + * whose `type` is `'level'`, or `null` when no level ancestor exists (orphaned + * node, top-level building node, etc.). Unlike `resolveLevelId`, this variant: + * + * - accepts a node **id** rather than a resolved node, saving the caller a + * `nodes[id]` lookup when only the id is at hand. + * - returns `null` instead of the `'default'` fallback, which lets callers + * distinguish "genuinely has no level" from "is a level". + * - has a loop guard (16 iterations) so a corrupt parent-chain cycle cannot + * hang the frame loop. + */ +export function findLevelAncestorId( + nodeId: AnyNodeId, + nodes: Record, +): string | null { + let current: AnyNode | undefined = nodes[nodeId] + let guard = 0 + while (current && guard < 16) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId] : undefined + guard += 1 + } + return null +} + /** * Returns the building id that contains the given level, or `null` if * the level is unparented or no enclosing building exists. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6d9fde2c6..9876c3058 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,6 +40,7 @@ export { } from './hooks/scene-registry/scene-registry' export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager' export { + findLevelAncestorId, initSpatialGridSync, resolveBuildingForLevel, resolveLevelId, @@ -88,12 +89,6 @@ export { resetSceneHistoryPauseDepth, resumeSceneHistory, } from './store/history-control' -export { default as useAlignmentGuides } from './store/use-alignment-guides' -export { - type WallSnapKind, - type WallSnapPoint, - default as useWallSnapIndicator, -} from './store/use-wall-snap-indicator' export { type ControlValue, type DoorAnimationState, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 4f0bb611b..f652fd7fb 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -980,6 +980,12 @@ export type Capabilities = { */ alignmentFootprint?: AlignmentFootprintConfig roofAccessory?: RoofAccessoryConfig + /** + * Kind cuts a hole in the ceiling surface it is attached to (e.g. recessed + * downlights). The viewer's `CeilingSystem` calls this for each child of a + * ceiling to collect extra holes before triangulating. See `CeilingCutCapability`. + */ + ceilingCut?: CeilingCutCapability paint?: PaintCapability /** * Kind is placed by clicking on a wall (door, window). When set, the @@ -1177,6 +1183,22 @@ export type RoofAccessoryConfig = { buildCut?: (node: AnyNode, hostSegment: AnyNode) => BufferGeometry | null } +/** + * Capability for kinds that cut a hole in their host ceiling when the node is + * attached to a ceiling surface (e.g. recessed downlights). The viewer's + * `CeilingSystem` queries children of a ceiling for this capability and merges + * the returned polygons as extra holes before triangulating, keeping the viewer + * free of per-kind branching. + * + * Returns a rotated-rectangle footprint in ceiling-local [x, z] plan space — + * the same coordinate space as `CeilingNode.polygon` and `.holes`. Return + * `null` when this particular instance should not cut a hole (e.g. a + * non-recessed variant of the same kind). + */ +export type CeilingCutCapability = { + buildCeilingHole: (node: AnyNode) => Array<[number, number]> | null +} + export type CapabilityCtx = { node: AnyNode } export type MovableConfig = { diff --git a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx index 6feb31e71..0cf517016 100644 --- a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx @@ -1,6 +1,6 @@ 'use client' -import { useAlignmentGuides } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' import { memo } from 'react' import { useFloorplanRender } from './floorplan-render-context' 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 7d01dcb46..628758f48 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 @@ -12,11 +12,11 @@ import { resolveAlignment, resumeSceneHistory, snapPointToGrid, - useAlignmentGuides, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { sfxEmitter } from '../../lib/sfx-bus' diff --git a/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx index c923c3c86..85fd3bb92 100644 --- a/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx @@ -1,6 +1,6 @@ 'use client' -import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/core' +import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor' import { memo } from 'react' import { useFloorplanRender } from './floorplan-render-context' 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 9a3be98fc..1a28525c0 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,12 +14,12 @@ import { pauseSceneHistory, resolveBuildingForLevel, resumeSceneHistory, - useAlignmentGuides, useInteractive, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { memo, diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx index 7d48bc1b1..636e8c975 100644 --- a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx +++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx @@ -1,6 +1,7 @@ 'use client' -import { type AlignmentGuide, sceneRegistry, useAlignmentGuides } from '@pascal-app/core' +import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index c7c87a929..a67837d6b 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -38,17 +38,16 @@ import { StairSegmentNode as StairSegmentNodeSchema, sampleWallCenterline, sceneRegistry, - useAlignmentGuides, useInteractive, useLiveNodeOverrides, useLiveTransforms, useScene, - useWallSnapIndicator, type WallNode, type WindowNode, ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, } from '@pascal-app/core' +import { useAlignmentGuides, useWallSnapIndicator } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Command, Ruler } from 'lucide-react' import { diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index e2d5e3d7e..278cab1df 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -1,6 +1,7 @@ 'use client' -import { sceneRegistry, useWallSnapIndicator, type WallSnapKind } from '@pascal-app/core' +import { sceneRegistry } from '@pascal-app/core' +import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' import { memo, useRef } from 'react' diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index b535dc318..3a1db9b29 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -7,9 +7,9 @@ import { type GridEvent, type LevelNode, resolveAlignment, - useAlignmentGuides, useScene, } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support' 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 ae00f981e..d09cdf8ee 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -15,13 +15,13 @@ import { type ShelfEvent, sceneRegistry, spatialGridManager, - useAlignmentGuides, useLiveTransforms, useScene, useSpatialQuery, type WallEvent, type WallNode, } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' 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 84a3e5a36..c6a417c96 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 @@ -15,10 +15,10 @@ import { resolveAlignment, sceneRegistry, spatialGridManager, - useAlignmentGuides, useLiveTransforms, useScene, } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 2290a7f58..129ea5490 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -9,9 +9,9 @@ import { RoofSegmentNode, resolveAlignment, sceneRegistry, - useAlignmentGuides, useScene, } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 15d8d971e..8c8121fe2 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -6,9 +6,9 @@ import { resolveAlignment, StairNode, StairSegmentNode, - useAlignmentGuides, useScene, } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 2fec080fd..567436669 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -215,6 +215,7 @@ export { duplicateStairSubtree } from './lib/stair-duplication' // nodes` so they don't need their own copy / their own tailwind-merge // dependency. export { cn } from './lib/utils' +export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAudio } from './store/use-audio' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export type { @@ -236,3 +237,8 @@ export { 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' +export { + type WallSnapKind, + type WallSnapPoint, + default as useWallSnapIndicator, +} from './store/use-wall-snap-indicator' diff --git a/packages/editor/src/lib/floorplan/apply-alignment.ts b/packages/editor/src/lib/floorplan/apply-alignment.ts index a180f68f0..f65754adf 100644 --- a/packages/editor/src/lib/floorplan/apply-alignment.ts +++ b/packages/editor/src/lib/floorplan/apply-alignment.ts @@ -3,9 +3,9 @@ import { type AlignmentGuide, collectAlignmentAnchors, resolveAlignment, - useAlignmentGuides, useScene, } from '@pascal-app/core' +import { useAlignmentGuides } from '@pascal-app/editor' /** * Fixed Figma-style alignment threshold (meters) for floor-plan placement / diff --git a/packages/core/src/store/use-alignment-guides.ts b/packages/editor/src/store/use-alignment-guides.ts similarity index 91% rename from packages/core/src/store/use-alignment-guides.ts rename to packages/editor/src/store/use-alignment-guides.ts index ae4991fb0..7ec81b096 100644 --- a/packages/core/src/store/use-alignment-guides.ts +++ b/packages/editor/src/store/use-alignment-guides.ts @@ -4,7 +4,7 @@ // and draws them. Both sides clear on commit, cancel, and unmount. import { create } from 'zustand' -import type { AlignmentGuide } from '../services/alignment' +import type { AlignmentGuide } from '@pascal-app/core' type AlignmentGuidesState = { guides: AlignmentGuide[] diff --git a/packages/core/src/store/use-wall-snap-indicator.ts b/packages/editor/src/store/use-wall-snap-indicator.ts similarity index 100% rename from packages/core/src/store/use-wall-snap-indicator.ts rename to packages/editor/src/store/use-wall-snap-indicator.ts diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 4a10c87e4..5af187eca 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -9,11 +9,10 @@ import { polygonAnchors, resolveAlignment, sceneRegistry, - useAlignmentGuides, useLiveTransforms, useScene, } from '@pascal-app/core' -import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { CursorSphere, markToolCancelConsumed, triggerSFX, useAlignmentGuides, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type * as THREE from 'three' diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index 7768d632c..ff3690d41 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -6,7 +6,6 @@ import { type GridEvent, type LevelNode, resolveAlignment, - useAlignmentGuides, useScene, } from '@pascal-app/core' import { @@ -14,6 +13,7 @@ import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, + useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index 37cc905c3..b2f291848 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -10,7 +10,6 @@ import { movingFootprintAnchors, resolveAlignment, sceneRegistry, - useAlignmentGuides, useLiveTransforms, useScene, } from '@pascal-app/core' @@ -19,6 +18,7 @@ import { DragBoundingBox, markToolCancelConsumed, triggerSFX, + useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 73f5eaa2e..b93704584 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -10,10 +10,9 @@ import { movingFootprintAnchors, resolveAlignment, snapPointToGrid, - useAlignmentGuides, useScene, } from '@pascal-app/core' -import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' +import { triggerSFX, useAlignmentGuides, 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/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index fd9e8d577..7dd715f74 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -6,7 +6,6 @@ import { isCurvedWall, sceneRegistry, spatialGridManager, - useAlignmentGuides, useLiveTransforms, useScene, type WallEvent, @@ -18,6 +17,7 @@ import { getSideFromNormal, isValidWallSideFace, triggerSFX, + useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index d50eff37a..72a5d632c 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -6,7 +6,6 @@ import { isCurvedWall, sceneRegistry, spatialGridManager, - useAlignmentGuides, useScene, type WallEvent, } from '@pascal-app/core' @@ -17,6 +16,7 @@ import { getSideFromNormal, isValidWallSideFace, triggerSFX, + useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index 78722bfa6..d1a6d5161 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -6,7 +6,6 @@ import { type DragAction, type FenceNode, resolveAlignment, - useAlignmentGuides, useScene, type WallNode, } from '@pascal-app/core' @@ -14,6 +13,7 @@ import { type FencePlanPoint, isSegmentLongEnough, snapFenceDraftPoint, + useAlignmentGuides, WALL_FINE_GRID_STEP, } from '@pascal-app/editor' diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index 7ca4e1473..0fca3ca77 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -7,7 +7,6 @@ import { getMaxWallCurveOffset, getWallChordFrame, normalizeWallCurveOffset, - useAlignmentGuides, useLiveNodeOverrides, useScene, type WallNode, @@ -19,6 +18,7 @@ import { isSegmentLongEnough, snapFenceDraftPoint, snapScalarToGrid, + useAlignmentGuides, WALL_FINE_GRID_STEP, } from '@pascal-app/editor' diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index c56f4c2ab..225b8f4af 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -3,7 +3,6 @@ import { type FenceNode, getWallCurveLength, - useAlignmentGuides, useScene, type WallNode, } from '@pascal-app/core' @@ -16,6 +15,7 @@ import { MeasurementPill, type MovingFenceEndpoint, triggerSFX, + useAlignmentGuides, useDragAction, useEditor, } from '@pascal-app/editor' diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 6d0716641..555d61af8 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -10,7 +10,6 @@ import { type LevelNode, type Point2D, resolveAlignment, - useAlignmentGuides, useScene, type WallMiterData, type WallNode, @@ -28,6 +27,7 @@ import { type SegmentAngleReference, snapFenceDraftPoint, triggerSFX, + useAlignmentGuides, useEditor, WALL_FINE_GRID_STEP, } from '@pascal-app/editor' diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 08b75bf3f..e7de994c5 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -1,4 +1,5 @@ import { + type AnyNode, getScaledDimensions, type HandleDescriptor, type ItemNode as ItemNodeType, @@ -221,6 +222,39 @@ export const itemDefinition: NodeDefinition = { }, applies: (node) => !(node as ItemNodeType).asset.attachTo, }, + // Recessed ceiling fixtures cut a hole in their host ceiling. The viewer's + // CeilingSystem queries this capability on each child of a ceiling so it + // never needs to branch on `node.type`. + ceilingCut: { + buildCeilingHole(rawNode: AnyNode): Array<[number, number]> | null { + const node = rawNode as ItemNodeType + if (!node.asset.recessed || node.asset.attachTo !== 'ceiling') return null + + // Inset slightly so the fixture's trim (widest part, sitting in the + // ceiling plane) overlaps the solid ceiling around the opening and hides + // the cut edge. Same constant the old ceiling-system used. + const INSET = 0.82 + const [width, , depth] = getScaledDimensions(node) + const halfW = (width / 2) * INSET + const halfD = (depth / 2) * INSET + const cx = node.position[0] + const cz = node.position[2] + const yaw = node.rotation?.[1] ?? 0 + const cos = Math.cos(yaw) + const sin = Math.sin(yaw) + + // Rotate the (inset) footprint corners about Y and translate to the + // item's plan position. Y-rotation of (dx, dz): (dx·cos + dz·sin, −dx·sin + dz·cos). + return ( + [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ] as Array<[number, number]> + ).map(([dx, dz]) => [cx + dx * cos + dz * sin, cz - dx * sin + dz * cos]) + }, + }, }, parametrics: itemParametrics, diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 11f7356ac..212350caf 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -11,7 +11,6 @@ import { type StairNode, type StairSegmentNode, sceneRegistry, - useAlignmentGuides, useLiveTransforms, useScene, type WallNode, @@ -21,6 +20,7 @@ import { clearRoofDuplicateMetadata, snapFenceDraftPoint, triggerSFX, + useAlignmentGuides, useEditor, type WallPlanPoint, } from '@pascal-app/editor' diff --git a/packages/nodes/src/shared/polygon-centroid-move.ts b/packages/nodes/src/shared/polygon-centroid-move.ts index 1155a1ce5..d344e6dc8 100644 --- a/packages/nodes/src/shared/polygon-centroid-move.ts +++ b/packages/nodes/src/shared/polygon-centroid-move.ts @@ -6,11 +6,10 @@ import { polygonAnchors, resolveAlignment, sceneRegistry, - useAlignmentGuides, useLiveTransforms, useScene, } from '@pascal-app/core' -import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' +import { snapPointToGrid, useAlignmentGuides, type WallPlanPoint } from '@pascal-app/editor' import type * as THREE from 'three' /** diff --git a/packages/nodes/src/shared/wall-opening-alignment.ts b/packages/nodes/src/shared/wall-opening-alignment.ts index bda92e227..ebadc4017 100644 --- a/packages/nodes/src/shared/wall-opening-alignment.ts +++ b/packages/nodes/src/shared/wall-opening-alignment.ts @@ -1,10 +1,9 @@ import { type AlignmentAnchor, resolveAlignment, - useAlignmentGuides, type WallNode, } from '@pascal-app/core' -import { snapToHalf } from '@pascal-app/editor' +import { snapToHalf, useAlignmentGuides } from '@pascal-app/editor' /** Figma-style alignment-snap threshold (meters), matching the move tools. */ export const WALL_OPENING_ALIGNMENT_THRESHOLD_M = 0.08 diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index dd19c3dc7..53d92b48e 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -12,10 +12,9 @@ import { ShelfNode, sceneRegistry, snapPointToGrid, - useAlignmentGuides, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { triggerSFX, useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import { type Group, Vector3 } from 'three' diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index 4bbb9bec4..54e29d22f 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -11,7 +11,6 @@ import { resolveAlignment, type SlabNode, sceneRegistry, - useAlignmentGuides, useLiveTransforms, useScene, type WallNode, @@ -21,6 +20,7 @@ import { markToolCancelConsumed, snapFenceDraftPoint, triggerSFX, + useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index e80cc372e..0d90075c8 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -6,7 +6,6 @@ import { type GridEvent, type LevelNode, resolveAlignment, - useAlignmentGuides, useScene, } from '@pascal-app/core' import { @@ -14,6 +13,7 @@ import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, + useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index 8928b3763..7616a3bcb 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -6,7 +6,6 @@ import { getMaxWallCurveOffset, getWallChordFrame, normalizeWallCurveOffset, - useAlignmentGuides, useLiveNodeOverrides, useScene, type WallNode, @@ -17,6 +16,7 @@ import { isSegmentLongEnough, snapScalarToGrid, snapWallDraftPoint, + useAlignmentGuides, WALL_FINE_GRID_STEP, type WallPlanPoint, } from '@pascal-app/editor' diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index e61c3244f..2f5f56e0b 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -11,9 +11,7 @@ import { pauseSceneHistory, resolveAlignment, resumeSceneHistory, - useAlignmentGuides, useScene, - useWallSnapIndicator, type WallNode, } from '@pascal-app/core' import { @@ -27,7 +25,9 @@ import { markToolCancelConsumed, snapWallDraftPointDetailed, triggerSFX, + useAlignmentGuides, useEditor, + useWallSnapIndicator, WALL_FINE_GRID_STEP, type WallPlanPoint, } from '@pascal-app/editor' diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 78a7ce88c..ad2799db6 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -7,9 +7,7 @@ import { type LevelNode, type Point2D, resolveAlignment, - useAlignmentGuides, useScene, - useWallSnapIndicator, type WallMiterData, type WallNode, } from '@pascal-app/core' @@ -25,7 +23,9 @@ import { type SegmentAngleReference, snapWallDraftPointDetailed, triggerSFX, + useAlignmentGuides, useEditor, + useWallSnapIndicator, WALL_FINE_GRID_STEP, type WallPlanPoint, } from '@pascal-app/editor' diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 52c59d930..efcd2a3ea 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -5,7 +5,6 @@ import { isCurvedWall, sceneRegistry, spatialGridManager, - useAlignmentGuides, useLiveTransforms, useScene, type WallEvent, @@ -19,6 +18,7 @@ import { isValidWallSideFace, snapToHalf, triggerSFX, + useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 0850ff074..3ebff6c89 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -5,7 +5,6 @@ import { isCurvedWall, sceneRegistry, spatialGridManager, - useAlignmentGuides, useScene, type WallEvent, WindowNode, @@ -18,6 +17,7 @@ import { isValidWallSideFace, snapToHalf, triggerSFX, + useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index f45b7dac6..496ec3336 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -2,8 +2,7 @@ import { type AnyNodeId, type CeilingNode, getEffectiveNode, - getScaledDimensions, - type ItemNode, + nodeRegistry, sceneRegistry, useScene, } from '@pascal-app/core' @@ -13,11 +12,6 @@ import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' type SceneNodes = ReturnType['nodes'] -// Recessed-fixture cutouts are inset slightly inside the item footprint so the -// fixture's trim (its widest part, sitting in the ceiling plane) overlaps the -// solid ceiling around the opening and hides the cut edge. -const RECESSED_HOLE_INSET = 0.82 - function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') if (!uv) return @@ -48,7 +42,7 @@ export const CeilingSystem = () => { // arrow rebuilds the mesh at pointer rate — zustand only learns // the final value on commit. Mirrors WallSystem / GeometrySystem. const effective = getEffectiveNode(node as CeilingNode) - const itemHoles = collectRecessedItemHoles(effective, nodes) + const itemHoles = collectCeilingHoles(effective, nodes) updateCeilingGeometry(effective, mesh, itemHoles) clearDirty(id as AnyNodeId) } @@ -60,47 +54,27 @@ export const CeilingSystem = () => { } /** - * Footprint cutouts for the ceiling's recessed child fixtures (e.g. recessed - * downlights). Each is a rotated rectangle in ceiling-local [x, z] space — the - * same space as `ceilingNode.polygon` — derived from the item's scaled - * dimensions and 2D position/yaw. Returned as extra holes so the cutout tracks - * the item automatically: adding, moving, or deleting a child re-dirties the - * ceiling (see node-actions / item renderer), which rebuilds this geometry. + * Collects ceiling-hole polygons from child nodes that declare the `ceilingCut` + * capability. Each child's `buildCeilingHole` returns a rotated-rectangle + * footprint in ceiling-local [x, z] space (or `null` to opt out), which is + * merged as an extra hole before triangulation. + * + * The viewer never branches on `child.type` — the dispatch goes through + * `nodeRegistry`, so any future kind (a heat lamp, a skylight panel, …) can + * participate just by declaring `capabilities.ceilingCut` on its definition. */ -function collectRecessedItemHoles( +function collectCeilingHoles( ceiling: CeilingNode, nodes: SceneNodes, ): Array> { const holes: Array> = [] for (const childId of ceiling.children ?? []) { - const child = nodes[childId] as ItemNode | undefined - if (!child || child.type !== 'item') continue - if (!child.asset.recessed || child.asset.attachTo !== 'ceiling') continue - - const [width, , depth] = getScaledDimensions(child) - const halfW = (width / 2) * RECESSED_HOLE_INSET - const halfD = (depth / 2) * RECESSED_HOLE_INSET - const cx = child.position[0] - const cz = child.position[2] - const yaw = child.rotation?.[1] ?? 0 - const cos = Math.cos(yaw) - const sin = Math.sin(yaw) - - // Corners of the (inset) footprint, rotated about Y and translated to the - // item's plan position. Y-rotation of (dx, dz): (dx·cos + dz·sin, -dx·sin + dz·cos). - const offsets: Array<[number, number]> = [ - [-halfW, -halfD], - [halfW, -halfD], - [halfW, halfD], - [-halfW, halfD], - ] - const corners: Array<[number, number]> = offsets.map(([dx, dz]) => [ - cx + dx * cos + dz * sin, - cz - dx * sin + dz * cos, - ]) - - holes.push(corners) + const child = nodes[childId as AnyNodeId] + if (!child) continue + const def = nodeRegistry.get(child.type) + const hole = def?.capabilities?.ceilingCut?.buildCeilingHole(child) + if (hole) holes.push(hole) } return holes diff --git a/packages/viewer/src/systems/item-light/item-light-system.tsx b/packages/viewer/src/systems/item-light/item-light-system.tsx index 5a93d8f4e..2f0df245d 100644 --- a/packages/viewer/src/systems/item-light/item-light-system.tsx +++ b/packages/viewer/src/systems/item-light/item-light-system.tsx @@ -1,5 +1,5 @@ import type { AnyNodeId, LevelNode } from '@pascal-app/core' -import { sceneRegistry, useInteractive, useScene } from '@pascal-app/core' +import { findLevelAncestorId, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useRef } from 'react' import { MathUtils, type PointLight, Vector3 } from 'three' @@ -35,19 +35,6 @@ const _itemPos = new Vector3() type SceneNodes = ReturnType['nodes'] type InteractiveState = ReturnType -function resolveNodeLevelId(nodeId: AnyNodeId, nodes: SceneNodes): string | null { - let current = nodes[nodeId] - let guard = 0 - - while (current && guard < 16) { - if (current.type === 'level') return current.id - current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined - guard += 1 - } - - return null -} - function scoreRegistration( reg: import('../../store/use-item-light-pool').LightRegistration, nodes: SceneNodes, @@ -80,7 +67,7 @@ function scoreRegistration( const dist = _camPos.distanceTo(_itemPos) / 200 // ── Level factor ────────────────────────────────────────────────────────── - const itemLevelId = resolveNodeLevelId(nodeId, nodes) + const itemLevelId = findLevelAncestorId(nodeId, nodes) let levelPenalty = 0 if (selectedLevelId) { From 75c98ad935d001a42ad14ce95f9a92e303fd3918 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 8 Jun 2026 22:00:40 +0530 Subject: [PATCH 8/8] chore: fix lint, untrack .claude/launch.json Run bun check --write to clear 8 Biome errors (formatting + import order + one unused import). Untrack .claude/launch.json and add it plus .claude/settings.local.json to .gitignore so local IDE/agent configs stop landing in commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/launch.json | 11 ---------- .gitignore | 5 +++++ .../tools/item/use-placement-coordinator.tsx | 1 - packages/editor/src/index.tsx | 6 +++--- .../editor/src/store/use-alignment-guides.ts | 2 +- packages/nodes/src/ceiling/move-tool.tsx | 8 +++++++- packages/nodes/src/column/tool.tsx | 7 ++++++- packages/nodes/src/door/floorplan.ts | 20 +++---------------- .../nodes/src/fence/move-endpoint-tool.tsx | 7 +------ .../src/shared/wall-opening-alignment.ts | 6 +----- packages/nodes/src/wall/tool.tsx | 4 +++- 11 files changed, 30 insertions(+), 47 deletions(-) delete mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 72b2c5ffd..000000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "editor", - "runtimeExecutable": "bun", - "runtimeArgs": ["run", "dev"], - "port": 3002 - } - ] -} diff --git a/.gitignore b/.gitignore index d01fc0bdd..38cfe8883 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,8 @@ og-test # Worktrees .worktrees + +# Local IDE/agent configs +.claude/launch.json +.claude/settings.local.json +.vscode/launch.json 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 10f82f3d1..0ef18a812 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -14,7 +14,6 @@ import { resolveLevelId, type ShelfEvent, sceneRegistry, - spatialGridManager, useLiveTransforms, useScene, useSpatialQuery, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index c4794e90d..d9ca0cc0e 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -100,11 +100,11 @@ export { isSegmentLongEnough, snapPointToGrid, snapScalarToGrid, - type WallDraftSnapKind, - type WallDraftSnapResult, snapWallDraftPoint, snapWallDraftPointDetailed, WALL_FINE_GRID_STEP, + type WallDraftSnapKind, + type WallDraftSnapResult, type WallPlanPoint, } from './components/tools/wall/wall-drafting' // `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the @@ -247,7 +247,7 @@ 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' export { + default as useWallSnapIndicator, type WallSnapKind, type WallSnapPoint, - default as useWallSnapIndicator, } from './store/use-wall-snap-indicator' diff --git a/packages/editor/src/store/use-alignment-guides.ts b/packages/editor/src/store/use-alignment-guides.ts index 7ec81b096..4f6f3eff6 100644 --- a/packages/editor/src/store/use-alignment-guides.ts +++ b/packages/editor/src/store/use-alignment-guides.ts @@ -3,8 +3,8 @@ // guides on pointermove; the renderer (a 2D / 3D guide layer) subscribes // and draws them. Both sides clear on commit, cancel, and unmount. -import { create } from 'zustand' import type { AlignmentGuide } from '@pascal-app/core' +import { create } from 'zustand' type AlignmentGuidesState = { guides: AlignmentGuide[] diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 5af187eca..cdd252612 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -12,7 +12,13 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { CursorSphere, markToolCancelConsumed, triggerSFX, useAlignmentGuides, useEditor } from '@pascal-app/editor' +import { + CursorSphere, + markToolCancelConsumed, + triggerSFX, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type * as THREE from 'three' diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index fae90e0b1..c34c4addf 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -12,7 +12,12 @@ import { snapPointToGrid, useScene, } from '@pascal-app/core' -import { getFloorStackPreviewPosition, triggerSFX, useAlignmentGuides, usePlacementPreview } from '@pascal-app/editor' +import { + getFloorStackPreviewPosition, + triggerSFX, + useAlignmentGuides, + 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/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts index 646d0a12c..bae089ef9 100644 --- a/packages/nodes/src/door/floorplan.ts +++ b/packages/nodes/src/door/floorplan.ts @@ -153,9 +153,7 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp // Swept wedge fill (light, low opacity) — reads as the open zone. children.push({ kind: 'path', - d: `M ${hX} ${hZ} L ${closedTipX} ${closedTipZ} ${arcPath - .replace(/^M [^A]+/, '') - .trim()} Z`, + d: `M ${hX} ${hZ} L ${closedTipX} ${closedTipZ} ${arcPath.replace(/^M [^A]+/, '').trim()} Z`, fill: accentColor, fillOpacity: showSelectedChrome ? 0.08 : 0.05, stroke: 'none', @@ -665,22 +663,10 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp const halfLeafX = dirX * halfWidth const halfLeafZ = dirZ * halfWidth // Leaf at the start edge: closed leaf points toward the centre. - drawSwingLeaf( - cx - halfLeafX, - cz - halfLeafZ, - halfLeafX, - halfLeafZ, - swingAngle * swingSign, - ) + drawSwingLeaf(cx - halfLeafX, cz - halfLeafZ, halfLeafX, halfLeafZ, swingAngle * swingSign) // Leaf at the end edge: closed leaf points the other way, swung // with the opposite sign so both meet perpendicular at the centre. - drawSwingLeaf( - cx + halfLeafX, - cz + halfLeafZ, - -halfLeafX, - -halfLeafZ, - -swingAngle * swingSign, - ) + drawSwingLeaf(cx + halfLeafX, cz + halfLeafZ, -halfLeafX, -halfLeafZ, -swingAngle * swingSign) } else { // Single leaf hinged at one end, strike at the opposite end. const hingeTangentSign = hingesSide === 'left' ? 1 : -1 diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index 225b8f4af..c5bc0a6ec 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -1,11 +1,6 @@ 'use client' -import { - type FenceNode, - getWallCurveLength, - useScene, - type WallNode, -} from '@pascal-app/core' +import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core' import { CursorSphere, type FencePlanPoint, diff --git a/packages/nodes/src/shared/wall-opening-alignment.ts b/packages/nodes/src/shared/wall-opening-alignment.ts index ebadc4017..43af9e469 100644 --- a/packages/nodes/src/shared/wall-opening-alignment.ts +++ b/packages/nodes/src/shared/wall-opening-alignment.ts @@ -1,8 +1,4 @@ -import { - type AlignmentAnchor, - resolveAlignment, - type WallNode, -} from '@pascal-app/core' +import { type AlignmentAnchor, resolveAlignment, type WallNode } from '@pascal-app/core' import { snapToHalf, useAlignmentGuides } from '@pascal-app/editor' /** Figma-style alignment-snap threshold (meters), matching the move tools. */ diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 09be99cea..cc8d71679 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -568,7 +568,9 @@ export const WallTool: React.FC = () => { useWallSnapIndicator .getState() .set( - snapResult.snap ? { x: gridPosition[0], z: gridPosition[1], kind: snapResult.snap } : null, + snapResult.snap + ? { x: gridPosition[0], z: gridPosition[1], kind: snapResult.snap } + : null, ) if (buildingState.current === 1) {