From be0f491bbde8f4754cda843ccc0c64780f852c21 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 7 Jun 2026 18:18:46 -0400 Subject: [PATCH 1/8] Fix relative move drag offsets --- .../tools/elevator/move-elevator-tool.tsx | 21 +- .../tools/item/use-placement-coordinator.tsx | 36 ++- .../registry/move-registry-node-tool.tsx | 11 +- packages/nodes/src/box-vent/move-tool.tsx | 57 ++-- packages/nodes/src/building/move-tool.tsx | 24 +- packages/nodes/src/chimney/move-tool.tsx | 55 ++-- packages/nodes/src/column/move-tool.tsx | 8 +- packages/nodes/src/cupola/move-tool.tsx | 57 ++-- packages/nodes/src/door/move-tool.tsx | 222 +++++++--------- packages/nodes/src/dormer/move-tool.tsx | 6 +- .../nodes/src/dormer/use-dormer-placement.ts | 34 ++- packages/nodes/src/eyebrow-vent/move-tool.tsx | 57 ++-- packages/nodes/src/gutter/move-tool.tsx | 47 ++-- packages/nodes/src/item/move-tool.tsx | 1 + packages/nodes/src/ridge-vent/move-tool.tsx | 73 +++--- packages/nodes/src/shared/move-roof-tool.tsx | 69 +++-- .../nodes/src/shared/relative-roof-drag.ts | 116 +++++++++ packages/nodes/src/skylight/move-tool.tsx | 79 ++---- packages/nodes/src/solar-panel/move-tool.tsx | 62 ++--- packages/nodes/src/turbine-vent/move-tool.tsx | 57 ++-- packages/nodes/src/window/move-tool.tsx | 243 ++++++++---------- 21 files changed, 712 insertions(+), 623 deletions(-) create mode 100644 packages/nodes/src/shared/relative-roof-drag.ts diff --git a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx index 5a82db0c..f98004fa 100644 --- a/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/move-elevator-tool.tsx @@ -40,6 +40,7 @@ export function MoveElevatorTool({ const onCommittedRef = useRef(onCommitted) const historyPausedRef = useRef(false) const previousGridPosRef = useRef<[number, number] | null>(null) + const dragAnchorRef = useRef<[number, number] | null>(null) const previewPositionRef = useRef([ movingNode.position[0], movingNode.position[1], @@ -73,6 +74,8 @@ export function MoveElevatorTool({ } pauseHistory() + dragAnchorRef.current = null + previousGridPosRef.current = null const movingNodeId = (movingNode as { id?: ElevatorNode['id'] }).id const meta = @@ -128,8 +131,12 @@ export function MoveElevatorTool({ } const onGridMove = (event: GridEvent) => { - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 + const rawX = Math.round(event.localPosition[0] * 2) / 2 + const rawZ = Math.round(event.localPosition[2] * 2) / 2 + const anchor = dragAnchorRef.current ?? [rawX, rawZ] + dragAnchorRef.current = anchor + const gridX = movingNode.position[0] + (rawX - anchor[0]) + const gridZ = movingNode.position[2] + (rawZ - anchor[1]) const supportY = resolveElevatorSupportY({ buildingId: supportBuildingId, preferredLevelId: supportLevelId, @@ -151,15 +158,7 @@ export function MoveElevatorTool({ } const onGridClick = (event: GridEvent) => { - const gridX = Math.round(event.localPosition[0] * 2) / 2 - const gridZ = Math.round(event.localPosition[2] * 2) / 2 - const supportY = resolveElevatorSupportY({ - buildingId: supportBuildingId, - preferredLevelId: supportLevelId, - x: gridX, - z: gridZ, - }) - const nextPosition: ElevatorNode['position'] = [gridX, supportY, gridZ] + const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current] wasCommitted = true clearPreview() 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 f854338d..b675a816 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -195,6 +195,8 @@ export interface PlacementCoordinatorConfig { initialState?: PlacementState /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */ defaultScale?: [number, number, number] + /** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */ + preserveFloorDragOffset?: boolean } export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode { @@ -405,6 +407,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // building-local, matching the draft's grid position and the guide // layer's frame. let alignmentCandidates: AlignmentAnchor[] | null = null + let floorDragAnchor: [number, number] | null = null // Reset placement state placementState.current = configRef.current.initialState ?? { @@ -526,6 +529,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Init draft ---- configRef.current.initDraft(gridPosition.current) + const preserveFloorDragOffset = + configRef.current.preserveFloorDragOffset === true && + placementState.current.surface === 'floor' && + !asset.attachTo + const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null // Sync cursor to the draft mesh's world position and rotation if (draftNode.current) { @@ -649,9 +657,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea detachItemSurfaceToFloor(event as unknown as ItemEvent) } - lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2]) + const floorEvent = + relativeFloorStart !== null + ? (() => { + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + const anchor = floorDragAnchor ?? [rawX, rawZ] + floorDragAnchor = anchor + return { + ...event, + localPosition: [ + relativeFloorStart.x + (rawX - anchor[0]), + event.localPosition[1], + relativeFloorStart.z + (rawZ - anchor[1]), + ] as [number, number, number], + } + })() + : event + + lastRawPos.current.set( + floorEvent.localPosition[0], + floorEvent.localPosition[1], + floorEvent.localPosition[2], + ) if (!cursorGroupRef.current) return - const result = floorStrategy.move(getContext(), event) + const result = floorStrategy.move(getContext(), floorEvent) if (!result) return // Figma-style alignment snap layered on top of the floor strategy's @@ -663,7 +693,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current let alignX = 0 let alignZ = 0 - const bypassAlign = event.nativeEvent?.altKey === true + const bypassAlign = floorEvent.nativeEvent?.altKey === true if (!bypassAlign && draft) { alignmentCandidates ??= collectAlignmentAnchors( useScene.getState().nodes, 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 827578f0..4fcadbfb 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 @@ -125,6 +125,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { * commit position consistent with the visible cursor. */ const lastCursorRef = useRef<[number, number, number]>(originalPosition) + const dragAnchorRef = useRef<[number, number] | null>(null) /** * Becomes true on the first `grid:move` after this move arms. Commits are * ignored until then so a click that *armed* this move (e.g. the trailing @@ -166,6 +167,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useEffect(() => { useScene.temporal.getState().pause() previousSnapRef.current = null + dragAnchorRef.current = null hasMovedRef.current = false rotationRef.current = originalRotationY shiftRef.current = false @@ -267,8 +269,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ) const onGridMove = (event: GridEvent) => { - let x = snapToGridStep(event.localPosition[0]) - let z = snapToGridStep(event.localPosition[2]) + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + const anchor = dragAnchorRef.current ?? [rawX, rawZ] + dragAnchorRef.current = anchor + + let x = originalPosition[0] + snapToGridStep(rawX - anchor[0]) + let z = originalPosition[2] + snapToGridStep(rawZ - anchor[1]) // Figma-style alignment snap layered on top of grid snap: when the // moving item's edge lines up (on X or Z) with another item's edge, diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index 312e3643..6f1cfd07 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -5,16 +5,18 @@ import { type BoxVentNode, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import BoxVentPreview from './preview' @@ -55,48 +57,39 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { const ventObj = sceneRegistry.nodes.get(node.id) if (ventObj) ventObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - let lastSnap: [number, number] | null = null + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() // Reparent if the cursor landed on a different segment than the @@ -124,7 +117,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/building/move-tool.tsx b/packages/nodes/src/building/move-tool.tsx index d3cc99b3..c1196e77 100644 --- a/packages/nodes/src/building/move-tool.tsx +++ b/packages/nodes/src/building/move-tool.tsx @@ -17,6 +17,7 @@ const Y_AXIS = new THREE.Vector3(0, 1, 0) export function MoveBuildingContent({ node }: { node: BuildingNode }) { const previousGridPosRef = useRef<[number, number] | null>(null) + const dragAnchorRef = useRef<[number, number] | null>(null) // Stable refs so the effect never needs node in its dependency array const nodeIdRef = useRef(node.id) @@ -29,9 +30,8 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { const pendingRotationRef = useRef(node.rotation[1] ?? 0) // Local-space offset from the building's origin to its bbox center. The - // floating drag button anchors at the bbox center, so we pin that point to - // the cursor during the drag — otherwise the raw origin (often nowhere near - // the visual center) would snap to the cursor and the building would jump. + // move preview preserves the first pointer-to-center delta, then uses this + // offset to write the origin while keeping rotation around the visual center. const centerOffsetLocalRef = useRef(new THREE.Vector3()) const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { @@ -66,8 +66,15 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { const offsetWork = new THREE.Vector3() const offsetAt = (rotationY: number) => offsetWork.copy(centerOffsetLocalRef.current).applyAxisAngle(Y_AXIS, rotationY) + const originalCenterOffset = offsetAt(originalRotationRef.current).clone() + const originalCenter: [number, number] = [ + originalPosition[0] + originalCenterOffset.x, + originalPosition[2] + originalCenterOffset.z, + ] useScene.temporal.getState().pause() + dragAnchorRef.current = null + previousGridPosRef.current = null // Publish the building's current pose to useLiveTransforms so the // floor-plan (and any other live consumers) can follow per-frame @@ -114,8 +121,12 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { } const onGridMove = (event: GridEvent) => { - const gridX = Math.round(event.position[0] * 2) / 2 - const gridZ = Math.round(event.position[2] * 2) / 2 + const rawX = Math.round(event.position[0] * 2) / 2 + const rawZ = Math.round(event.position[2] * 2) / 2 + const anchor = dragAnchorRef.current ?? [rawX, rawZ] + dragAnchorRef.current = anchor + const gridX = originalCenter[0] + (rawX - anchor[0]) + const gridZ = originalCenter[1] + (rawZ - anchor[1]) if ( previousGridPosRef.current && @@ -138,8 +149,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { } const onGridClick = (event: GridEvent) => { - const gridX = Math.round(event.position[0] * 2) / 2 - const gridZ = Math.round(event.position[2] * 2) / 2 + const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter wasCommitted = true diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index ca57e0a9..c72908a5 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -6,7 +6,6 @@ import { ChimneyNode as ChimneyNodeSchema, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, @@ -15,7 +14,7 @@ import { triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { createRelativeRoofDrag, type RelativeRoofDragTarget } from '../shared/relative-roof-drag' import ChimneyPreview from './preview' const tmpMatrix = new THREE.Matrix4() @@ -84,38 +83,36 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { } } - const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag({ + position: [...node.position] as [number, number, number], + roofSegmentId: node.roofSegmentId, + }) - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const updatePreview = (event: RoofEvent) => { + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target + + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 const prev = lastSnapRef.current if (!prev || prev[0] !== sx || prev[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const xform = computeSegmentXform(hit.segment.id) + const xform = computeSegmentXform(target.segment.id) if (!xform) return setSegmentXform(xform) - setHitLocal([hit.localX, hit.localY, hit.localZ]) - setPreviewSegment(hit.segment) + setHitLocal([target.localX, target.localY, target.localZ]) + setPreviewSegment(target.segment) event.stopPropagation() } const onClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return const state = useScene.getState() // Strip the `isNew` flag — only used to mark a duplicate clone @@ -135,23 +132,23 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { const committed = ChimneyNodeSchema.parse({ ...node, id: undefined as never, - roofSegmentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], + roofSegmentId: target.segment.id, + position: [target.localX, target.localY, target.localZ], metadata: cleanedMeta, }) - state.createNode(committed, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) + state.createNode(committed, target.segment.id as AnyNodeId) + state.dirtyNodes.add(target.segment.id as AnyNodeId) setSelection({ selectedIds: [committed.id] }) } else { const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined state.updateNode(node.id as AnyNodeId, { - roofSegmentId: hit.segment.id, - parentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], + roofSegmentId: target.segment.id, + parentId: target.segment.id, + position: [target.localX, target.localY, target.localZ], metadata: cleanedMeta, }) if (prevSegmentId) state.dirtyNodes.add(prevSegmentId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) + state.dirtyNodes.add(target.segment.id as AnyNodeId) setSelection({ selectedIds: [node.id] }) } setMovingNode(null) diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index 995425eb..25cea8de 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -70,6 +70,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { let rotationY = node.rotation // Latest previewed position, so an R/T press can re-apply at the spot. let lastPosition: [number, number, number] = node.position + let dragAnchor: [number, number] | null = null const meta = typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) @@ -111,8 +112,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const onGridMove = (event: GridEvent) => { hasMoved = true - let x = snapToGridStep(event.localPosition[0]) - let z = snapToGridStep(event.localPosition[2]) + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + dragAnchor ??= [rawX, rawZ] + let x = node.position[0] + snapToGridStep(rawX - dragAnchor[0]) + let z = node.position[2] + snapToGridStep(rawZ - dragAnchor[1]) // Figma-style alignment snap on top of grid snap; Alt bypasses. The // guide connects to the candidate's nearest real anchor (resolver diff --git a/packages/nodes/src/cupola/move-tool.tsx b/packages/nodes/src/cupola/move-tool.tsx index ea1d6e6e..2d6f9b27 100644 --- a/packages/nodes/src/cupola/move-tool.tsx +++ b/packages/nodes/src/cupola/move-tool.tsx @@ -5,16 +5,18 @@ import { type CupolaNode, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import CupolaPreview from './preview' @@ -53,48 +55,39 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { const cupolaObj = sceneRegistry.nodes.get(node.id) if (cupolaObj) cupolaObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - let lastSnap: [number, number] | null = null + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined @@ -118,7 +111,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 6256d706..66813b67 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -66,6 +66,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } let currentWallId: string | null = movingDoorNode.parentId + let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null + let lastTarget: { + wallNode: WallEvent['node'] + wallId: string + side: DoorNode['side'] + itemRotation: number + cursorRotation: number + clampedX: number + clampedY: number + valid: boolean + event: WallEvent + } | null = null const markWallDirty = (wallId: string | null) => { if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) @@ -131,7 +143,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } } - const onWallEnter = (event: WallEvent) => { + const resolveMoveTarget = (event: WallEvent) => { if (!isValidWallSideFace(event.normal)) return if (isCurvedWall(event.node)) { hideCursor() @@ -141,9 +153,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) + const rawLocalX = event.localPosition[0] + if (!dragAnchor || dragAnchor.wallId !== event.node.id) { + dragAnchor = { + wallId: event.node.id, + rawX: rawLocalX, + startX: event.node.id === original.parentId ? original.position[0] : rawLocalX, + } + } + const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) const localX = resolveWallSlideAlignment({ wallNode: event.node, - rawLocalX: event.localPosition[0], + rawLocalX: targetLocalX, width: movingDoorNode.width, candidates: alignmentCandidates, bypass: event.nativeEvent?.altKey === true, @@ -155,24 +176,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => movingDoorNode.height, ) - const prevWallId = currentWallId - currentWallId = event.node.id - - useScene.getState().updateNode(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - useLiveTransforms.getState().set(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: itemRotation, - }) - - if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) - markWallDirtyThrottled(event.node.id) - const valid = !hasWallChildOverlap( event.node.id, clampedX, @@ -182,17 +185,62 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => movingDoorNode.id, ) + return { + wallNode: event.node, + wallId: event.node.id, + side, + itemRotation, + cursorRotation, + clampedX, + clampedY, + valid, + event, + } + } + + const applyPreview = (target: NonNullable) => { + if (currentWallId !== target.wallId) { + useScene.getState().updateNode(movingDoorNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, + }) + markWallDirty(currentWallId) + currentWallId = target.wallId + } else { + const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId) + if (doorMesh) { + doorMesh.position.set(target.clampedX, target.clampedY, 0) + doorMesh.rotation.set(0, target.itemRotation, 0) + doorMesh.updateMatrixWorld(true) + } + } + useLiveTransforms.getState().set(movingDoorNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: target.itemRotation, + }) + markWallDirtyThrottled(target.wallId) + updateCursor( wallLocalToWorld( - event.node, - clampedX, - clampedY, + target.wallNode, + target.clampedX, + target.clampedY, getLevelYOffset(), - getSlabElevation(event), + getSlabElevation(target.event), ), - cursorRotation, - valid, + target.cursorRotation, + target.valid, ) + } + + const onWallEnter = (event: WallEvent) => { + const target = resolveMoveTarget(event) + if (!target) return + lastTarget = target + applyPreview(target) event.stopPropagation() } @@ -204,69 +252,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } if (event.node.parentId !== getLevelId()) return - const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: movingDoorNode.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, - }) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - movingDoorNode.width, - movingDoorNode.height, - ) - - if (currentWallId !== event.node.id) { - // Wall changed mid-move: must updateNode to reparent - useScene.getState().updateNode(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - markWallDirty(currentWallId) - currentWallId = event.node.id - } else { - // Same wall: update Three.js mesh directly to avoid store churn - // collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions - const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId) - if (doorMesh) { - doorMesh.position.set(clampedX, clampedY, 0) - doorMesh.rotation.set(0, itemRotation, 0) - doorMesh.updateMatrixWorld(true) - } - } - useLiveTransforms.getState().set(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: itemRotation, - }) - markWallDirtyThrottled(event.node.id) - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingDoorNode.width, - movingDoorNode.height, - movingDoorNode.id, - ) - - updateCursor( - wallLocalToWorld( - event.node, - clampedX, - clampedY, - getLevelYOffset(), - getSlabElevation(event), - ), - cursorRotation, - valid, - ) + const target = resolveMoveTarget(event) + if (!target) return + lastTarget = target + applyPreview(target) event.stopPropagation() } @@ -275,31 +264,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => if (isCurvedWall(event.node)) return if (event.node.parentId !== getLevelId()) return - const { side, itemRotation } = getPlacementOrientation(event) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: movingDoorNode.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, - }) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - movingDoorNode.width, - movingDoorNode.height, - ) - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingDoorNode.width, - movingDoorNode.height, - movingDoorNode.id, - ) - if (!valid) return + const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) + if (!target?.valid) return let placedId: string @@ -311,13 +277,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => delete cloned.id const node = DoorNode.parse({ ...cloned, - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - wallId: event.node.id, - parentId: event.node.id, + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + wallId: target.wallId, + parentId: target.wallId, }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) + useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id } else { useScene.getState().updateNode(movingDoorNode.id, { @@ -331,21 +297,21 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => useScene.temporal.getState().resume() useScene.getState().updateNode(movingDoorNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, metadata: {}, }) - if (original.parentId && original.parentId !== event.node.id) { + if (original.parentId && original.parentId !== target.wallId) { markWallDirty(original.parentId) } placedId = movingDoorNode.id } - markWallDirty(event.node.id) + markWallDirty(target.wallId) useLiveTransforms.getState().clear(movingDoorNode.id) useScene.temporal.getState().pause() @@ -359,6 +325,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onWallLeave = () => { hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) + dragAnchor = null + lastTarget = null if (isNew) return if (currentWallId && currentWallId !== original.parentId) { markWallDirty(currentWallId) diff --git a/packages/nodes/src/dormer/move-tool.tsx b/packages/nodes/src/dormer/move-tool.tsx index 0001aac2..150fdd95 100644 --- a/packages/nodes/src/dormer/move-tool.tsx +++ b/packages/nodes/src/dormer/move-tool.tsx @@ -51,6 +51,7 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { const originalRotation = node.rotation ?? 0 const originalMetadata = node.metadata + // biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes. useEffect(() => { if (!isNew) { useScene.getState().updateNode(node.id as AnyNodeId, { @@ -71,11 +72,14 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { }) } } - // biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes. }, [node.id, isNew]) const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({ initialRotation: originalRotation, + relativeStart: { + position: [...node.position] as [number, number, number], + roofSegmentId: node.roofSegmentId, + }, onCommit: (hit, rotation) => { const state = useScene.getState() diff --git a/packages/nodes/src/dormer/use-dormer-placement.ts b/packages/nodes/src/dormer/use-dormer-placement.ts index 97b31012..1d88fb43 100644 --- a/packages/nodes/src/dormer/use-dormer-placement.ts +++ b/packages/nodes/src/dormer/use-dormer-placement.ts @@ -10,6 +10,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import * as THREE from 'three' +import { createRelativeRoofDrag } from '../shared/relative-roof-drag' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry' @@ -50,6 +51,10 @@ export type DormerPlacementHit = { */ export function useDormerPlacement(opts: { initialRotation?: number + relativeStart?: { + position: [number, number, number] + roofSegmentId?: string + } onCommit: (hit: DormerPlacementHit, rotation: number) => void }): { activeBuildingId: string | undefined @@ -66,6 +71,7 @@ export function useDormerPlacement(opts: { // Mirror of `ghostRotation` so the click handler (registered once // inside useEffect) can read the latest value at commit time. const ghostRotationRef = useRef(opts.initialRotation ?? 0) + const relativeStartRef = useRef(opts.relativeStart) // Latest commit callback, captured via ref so the useEffect doesn't // need it in its dep list (we don't want to re-register listeners // every time the parent rerenders). @@ -90,9 +96,23 @@ export function useDormerPlacement(opts: { } } + const roofDrag = relativeStartRef.current + ? createRelativeRoofDrag(relativeStartRef.current) + : null + let lastRelativeHit: DormerPlacementHit | null = null + + const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => { + if (roofDrag) return roofDrag.resolve(event) + return resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + } + const updatePreview = (event: RoofEvent) => { const wx = event.position[0] - const wy = event.position[1] const wz = event.position[2] const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M @@ -103,8 +123,9 @@ export function useDormerPlacement(opts: { lastSnapRef.current = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) + const hit = resolvePlacementHit(event) if (!hit) return + if (roofDrag) lastRelativeHit = hit const xform = computeSegmentXform(hit.segment.id) if (!xform) return setSegmentXform(xform) @@ -118,12 +139,9 @@ export function useDormerPlacement(opts: { } const onClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) + const hit = roofDrag + ? (lastRelativeHit ?? resolvePlacementHit(event)) + : resolvePlacementHit(event) if (!hit) return onCommitRef.current(hit, ghostRotationRef.current) triggerSFX('sfx:item-place') diff --git a/packages/nodes/src/eyebrow-vent/move-tool.tsx b/packages/nodes/src/eyebrow-vent/move-tool.tsx index 82b3ab33..036462d1 100644 --- a/packages/nodes/src/eyebrow-vent/move-tool.tsx +++ b/packages/nodes/src/eyebrow-vent/move-tool.tsx @@ -5,16 +5,18 @@ import { type EyebrowVentNode, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import EyebrowVentPreview from './preview' @@ -54,48 +56,39 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) const ventObj = sceneRegistry.nodes.get(node.id) if (ventObj) ventObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - let lastSnap: [number, number] | null = null + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined @@ -119,7 +112,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx index a2813fc4..57295ba0 100644 --- a/packages/nodes/src/gutter/move-tool.tsx +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -12,7 +12,7 @@ import { } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { createRelativeRoofDrag } from '../shared/relative-roof-drag' import { type EaveSnap, resolveEaveSnap } from './eave-snap' import GutterPreview from './preview' @@ -22,6 +22,11 @@ type PreviewTarget = { snap: EaveSnap } +type GutterDragTarget = { + segment: RoofSegmentNode + snap: EaveSnap +} + /** * Gutter move tool. Mirrors the ridge-vent move flow — ghost follows * the cursor over any roof segment, click commits the new position + @@ -65,23 +70,30 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { if (gutterObj) gutterObj.visible = false let lastSnap: [number, number] | null = null + let lastTarget: GutterDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) + + const resolveTarget = (event: RoofEvent): GutterDragTarget | null => { + const target = roofDrag.resolve(event) + if (!target) return null + return { + segment: target.segment, + snap: resolveEaveSnap(target.segment, target.localX, target.localZ), + } + } const updatePreview = (event: RoofEvent) => { const roof = event.node as RoofNode - const hit = resolveRoofSegmentHit( - roof, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return + const target = resolveTarget(event) + if (!target) return + lastTarget = target // Same snap math as the placement tool — picking-up and putting- // down round-trip identically. roofType-aware: hip/flat picks // ±X or ±Z based on which slope the cursor is on; shed always // snaps to its low (+Z) eave; gable / gambrel / mansard / dutch // stay on ±Z. - const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + const { snap } = target const sx = Math.round(snap.eaveX * 20) / 20 const sz = Math.round(snap.eaveZ * 20) / 20 @@ -96,8 +108,8 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { rotation: roof.rotation ?? 0, }, segment: { - position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number], - rotation: hit.segment.rotation ?? 0, + position: (target.segment.position ?? [0, 0, 0]) as [number, number, number], + rotation: target.segment.rotation ?? 0, }, snap, }) @@ -105,15 +117,10 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId - const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + const target = lastTarget ?? resolveTarget(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId + const { snap } = target const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined diff --git a/packages/nodes/src/item/move-tool.tsx b/packages/nodes/src/item/move-tool.tsx index 88fc002d..236047da 100644 --- a/packages/nodes/src/item/move-tool.tsx +++ b/packages/nodes/src/item/move-tool.tsx @@ -88,6 +88,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) { : getInitialState(node), // Preserve the original item's scale so Y-position calculations use the correct height. defaultScale: isNew ? node.scale : undefined, + preserveFloorDragOffset: true, initDraft: (gridPosition) => { if (isNew) { // Duplicate: floor items get a draft immediately; wall/ceiling diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index f7890d6f..e72fa2df 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -5,18 +5,25 @@ import { emitter, type RidgeVentNode, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' -import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' +import { getSurfaceY } from '../shared/roof-surface' import RidgeVentPreview from './preview' +type RidgeVentDragTarget = Pick & { + localY: number + localZ: 0 +} + /** * Ridge-vent move tool. Mirrors the box-vent move flow — ghost follows * the cursor over any roof segment, click commits the new position + @@ -51,46 +58,48 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { const ventObj = sceneRegistry.nodes.get(node.id) if (ventObj) ventObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] + let lastSnap: [number, number] | null = null + let lastTarget: RidgeVentDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) + + const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => { + const target = roofDrag.resolve(event) + if (!target) return null + return { + segment: target.segment, + localX: target.localX, + localY: getSurfaceY(target.localX, 0, target.segment), + localZ: 0, + } } - let lastSnap: [number, number] | null = null - const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = resolveTarget(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? resolveTarget(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined @@ -114,7 +123,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 5ece7396..0352934d 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -41,6 +41,7 @@ export const MoveRoofTool: React.FC<{ }, []) const previousGridPosRef = useRef<[number, number] | null>(null) + const dragAnchorRef = useRef<[number, number] | null>(null) const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { const obj = sceneRegistry.nodes.get(movingNode.id) @@ -78,6 +79,8 @@ export const MoveRoofTool: React.FC<{ useEffect(() => { useScene.temporal.getState().pause() + dragAnchorRef.current = null + previousGridPosRef.current = null const meta = typeof movingNode.metadata === 'object' && movingNode.metadata !== null @@ -255,6 +258,24 @@ export const MoveRoofTool: React.FC<{ return [buildingLocalX, buildingLocalZ] } + const localPositionToToolLocal = ( + position: [number, number, number], + ): [number, number, number] => { + if ( + (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') && + movingNode.parentId + ) { + const parentObj = sceneRegistry.nodes.get(movingNode.parentId) + if (parentObj) { + const point = parentObj.localToWorld(new THREE.Vector3(...position)) + if (buildingObj) buildingObj.worldToLocal(point) + return [point.x, point.y, point.z] + } + } + + return position + } + const onGridMove = (event: GridEvent) => { const y = event.position[1] @@ -263,29 +284,40 @@ export const MoveRoofTool: React.FC<{ walls: levelWalls, fences: levelFences, }) - // Layer alignment snap on top (top-level stair/roof). Recompute the - // world point from the aligned building-local point so it stays correct - // under building rotation. - const [lx, lz] = alignLocalPoint( + const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y) + const [rawLocalX, rawLocalZ] = computeLocal( + rawGridX, + rawGridZ, + y, snappedLocal[0], snappedLocal[1], - event.nativeEvent?.altKey === true, ) - const [gridX, , gridZ] = localToWorldPoint([lx, lz], y) + const anchor = dragAnchorRef.current ?? [rawLocalX, rawLocalZ] + dragAnchorRef.current = anchor + + let localX = movingNode.position[0] + (rawLocalX - anchor[0]) + let localZ = movingNode.position[2] + (rawLocalZ - anchor[1]) + + if (alignTopLevel) { + const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true) + localX = aligned[0] + localZ = aligned[1] + } if ( previousGridPosRef.current && - (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) + (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { triggerSFX('sfx:grid-snap') } - previousGridPosRef.current = [gridX, gridZ] + previousGridPosRef.current = [localX, localZ] - const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) lastLocalPosition = [localX, movingNode.position[1], localZ] const previewPosition = getPreviewPosition(lastLocalPosition) - setCursorWorldPos(isFloorPlaced ? previewPosition : [lx, event.localPosition[1], lz]) + setCursorWorldPos( + isFloorPlaced ? previewPosition : localPositionToToolLocal(lastLocalPosition), + ) // Directly update the Three.js mesh — no store update during drag const mesh = sceneRegistry.nodes.get(movingNode.id) @@ -302,26 +334,13 @@ export const MoveRoofTool: React.FC<{ // Floor-placed parents (stairs) stay in their committed local frame; // the lifted Y remains presentation-only in the 3D view. useLiveTransforms.getState().set(movingNode.id, { - position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ], + position: lastLocalPosition, rotation: pendingRotation, }) } const onGridClick = (event: GridEvent) => { - const y = event.position[1] - const snappedLocal = snapFenceDraftPoint({ - point: [event.localPosition[0], event.localPosition[2]], - walls: levelWalls, - fences: levelFences, - }) - const [lx, lz] = alignLocalPoint( - snappedLocal[0], - snappedLocal[1], - event.nativeEvent?.altKey === true, - ) - const [gridX, , gridZ] = localToWorldPoint([lx, lz], y) - - const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) + const [localX, , localZ] = lastLocalPosition useAlignmentGuides.getState().clear() wasCommitted = true diff --git a/packages/nodes/src/shared/relative-roof-drag.ts b/packages/nodes/src/shared/relative-roof-drag.ts new file mode 100644 index 00000000..2bd2b8e1 --- /dev/null +++ b/packages/nodes/src/shared/relative-roof-drag.ts @@ -0,0 +1,116 @@ +import { + type AnyNodeId, + type RoofEvent, + type RoofNode, + type RoofSegmentNode, + sceneRegistry, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import * as THREE from 'three' +import { type RoofSegmentHit, resolveRoofSegmentHit } from './roof-segment-hit' +import { getSurfaceY } from './roof-surface' + +export type RelativeRoofDragTarget = { + segment: RoofSegmentNode + localX: number + localY: number + localZ: number + hit: RoofSegmentHit +} + +type RelativeRoofDragState = { + segmentId: string + anchor: [number, number] + start: [number, number, number] + current: [number, number, number] + surfaceOffsetY: number +} + +export function roofSegmentLocalToBuildingLocal( + segmentId: string, + position: [number, number, number], +): [number, number, number] { + const segmentObj = sceneRegistry.nodes.get(segmentId as AnyNodeId) + if (!segmentObj) return position + + const point = segmentObj.localToWorld(new THREE.Vector3(...position)) + const buildingId = useViewer.getState().selection.buildingId + const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + if (buildingObj) buildingObj.worldToLocal(point) + return [point.x, point.y, point.z] +} + +export function createRelativeRoofDrag(original: { + position: [number, number, number] + roofSegmentId?: string +}): { + resolve: (event: RoofEvent) => RelativeRoofDragTarget | null +} { + let state: RelativeRoofDragState | null = null + + const getPositionInSegment = ( + position: [number, number, number], + fromSegmentId: string | undefined, + segment: RoofSegmentNode, + ): [number, number, number] => { + if (fromSegmentId === segment.id) return position + + const fromSegmentObj = fromSegmentId + ? sceneRegistry.nodes.get(fromSegmentId as AnyNodeId) + : null + const targetSegmentObj = sceneRegistry.nodes.get(segment.id as AnyNodeId) + if (!(fromSegmentObj && targetSegmentObj)) return position + + const point = fromSegmentObj.localToWorld(new THREE.Vector3(...position)) + targetSegmentObj.worldToLocal(point) + return [point.x, point.y, point.z] + } + + const getStartPositionForSegment = ( + segment: RoofSegmentNode, + previousState: RelativeRoofDragState | null, + ): [number, number, number] => { + if (previousState) { + return getPositionInSegment(previousState.current, previousState.segmentId, segment) + } + + if (original.roofSegmentId === segment.id) return original.position + + return getPositionInSegment(original.position, original.roofSegmentId, segment) + } + + return { + resolve(event) { + const hit = resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return null + + if (!state || state.segmentId !== hit.segment.id) { + const start = getStartPositionForSegment(hit.segment, state) + state = { + segmentId: hit.segment.id, + anchor: [hit.localX, hit.localZ], + start, + current: start, + surfaceOffsetY: start[1] - getSurfaceY(start[0], start[2], hit.segment), + } + } + + const localX = state.start[0] + (hit.localX - state.anchor[0]) + const localZ = state.start[2] + (hit.localZ - state.anchor[1]) + const localY = getSurfaceY(localX, localZ, hit.segment) + state.surfaceOffsetY + state.current = [localX, localY, localZ] + return { + segment: hit.segment, + localX, + localY, + localZ, + hit, + } + }, + } +} diff --git a/packages/nodes/src/skylight/move-tool.tsx b/packages/nodes/src/skylight/move-tool.tsx index 07a1e993..b93bfc4c 100644 --- a/packages/nodes/src/skylight/move-tool.tsx +++ b/packages/nodes/src/skylight/move-tool.tsx @@ -11,35 +11,16 @@ import { useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import SkylightPreview from './preview' -function resolveSegmentFromWorldPoint( - roof: RoofNode, - worldX: number, - worldY: number, - worldZ: number, - state: ReturnType, -): { segment: RoofSegmentNode; localX: number; localY: number; localZ: number } | null { - const worldPt = new THREE.Vector3(worldX, worldY, worldZ) - for (const childId of roof.children ?? []) { - const seg = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined - if (seg?.type !== 'roof-segment') continue - const segObj = sceneRegistry.nodes.get(seg.id) - if (!segObj) continue - segObj.updateWorldMatrix(true, false) - const local = segObj.worldToLocal(worldPt.clone()) - if (Math.abs(local.x) <= seg.width / 2 && Math.abs(local.z) <= seg.depth / 2) { - return { segment: seg, localX: local.x, localY: local.y, localZ: local.z } - } - } - return null -} - export default function MoveSkylightTool({ node }: { node: SkylightNode }) { const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -81,19 +62,10 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { const skylightObj = sceneRegistry.nodes.get(node.id) if (skylightObj) skylightObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (buildingObj) { - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - return [wx, wy, wz] - } - let lastSnapX = 0 let lastSnapZ = 0 + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) // Resolve which segment the cursor is over, then derive the same // preview transform stack the placement tool uses (`skylight/tool.tsx`): @@ -103,20 +75,22 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { // same via its `if (!hit) return` guard. const updateFromHit = (event: RoofEvent) => { const roof = event.node as RoofNode - const hit = resolveRoofSegmentHit( - roof, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) { + const target = roofDrag.resolve(event) + if (!target) { setHasHit(false) return false } - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + lastTarget = target + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((roof.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2])) + setPreviewYaw((roof.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) setHasHit(true) return true } @@ -139,19 +113,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { } const onRoofClick = (event: RoofEvent) => { - const roof = event.node as RoofNode const st = useScene.getState() - const hit = resolveSegmentFromWorldPoint( - roof, - event.position[0], - event.position[1], - event.position[2], - st, - ) - if (!hit) return + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return - const targetSegmentId = hit.segment.id as AnyNodeId + const targetSegmentId = target.segment.id as AnyNodeId const finalRotation = original.rotation st.updateNode(node.id as AnyNodeId, { @@ -166,7 +133,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: finalRotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/solar-panel/move-tool.tsx b/packages/nodes/src/solar-panel/move-tool.tsx index b181c610..c17973c7 100644 --- a/packages/nodes/src/solar-panel/move-tool.tsx +++ b/packages/nodes/src/solar-panel/move-tool.tsx @@ -4,17 +4,19 @@ import { type AnyNodeId, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, type SolarPanelNode, sceneRegistry, useScene, } from '@pascal-app/core' import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' // MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding @@ -86,27 +88,18 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { const panelObj = sceneRegistry.nodes.get(node.id) if (panelObj) panelObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (buildingObj) { - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - return [wx, wy, wz] - } - let lastSnapX = 0 let lastSnapZ = 0 + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updateGhost = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (sx !== lastSnapX || sz !== lastSnapZ) { triggerSFX('sfx:grid-snap') lastSnapX = sx @@ -119,35 +112,32 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { // because analytical normals are computed in segment-local space // and the yaw is applied explicitly, avoiding any world-vs-local // normal mismatch. - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const segLocalNormal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(segLocalNormal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) setHasHit(true) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const roof = event.node as RoofNode const st = useScene.getState() - const hit = resolveRoofSegmentHit( - roof, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return - const targetSegmentId = hit.segment.id as AnyNodeId + const targetSegmentId = target.segment.id as AnyNodeId // Compute segment-local normal for the committed node so the // renderer's surfaceQuat + outer segment.rotation compose to // the same world orientation the ghost showed. - const segLocalNormal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment) st.updateNode(node.id as AnyNodeId, { position: original.position, @@ -161,7 +151,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, // Segment-local normal — must stay consistent with getAnalyticalNormal // semantics so the renderer's surfaceQuat is in the correct frame. diff --git a/packages/nodes/src/turbine-vent/move-tool.tsx b/packages/nodes/src/turbine-vent/move-tool.tsx index 5db5615c..3f45e9bb 100644 --- a/packages/nodes/src/turbine-vent/move-tool.tsx +++ b/packages/nodes/src/turbine-vent/move-tool.tsx @@ -4,17 +4,19 @@ import { type AnyNodeId, emitter, type RoofEvent, - type RoofNode, type RoofSegmentNode, sceneRegistry, type TurbineVentNode, useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + roofSegmentLocalToBuildingLocal, +} from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import TurbineVentPreview from './preview' @@ -54,48 +56,39 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) const ventObj = sceneRegistry.nodes.get(node.id) if (ventObj) ventObj.visible = false - const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - let lastSnap: [number, number] | null = null + let lastTarget: RelativeRoofDragTarget | null = null + const roofDrag = createRelativeRoofDrag(original) const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - const wy = event.position[1] - const wz = event.position[2] + const target = roofDrag.resolve(event) + if (!target) return + lastTarget = target - const sx = Math.round(wx * 20) / 20 - const sz = Math.round(wz * 20) / 20 + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz) - if (!hit) return - - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewPos( + roofSegmentLocalToBuildingLocal(target.segment.id, [ + target.localX, + target.localY, + target.localZ, + ]), + ) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - const targetSegmentId = hit.segment.id as AnyNodeId + const target = lastTarget ?? roofDrag.resolve(event) + if (!target) return + const targetSegmentId = target.segment.id as AnyNodeId const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined @@ -119,7 +112,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, hit.localY, hit.localZ], + position: [target.localX, target.localY, target.localZ], rotation: original.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 9d5ba082..031b795c 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -86,6 +86,24 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } let currentWallId: string | null = movingWindowNode.parentId + let dragAnchor: { + wallId: string + rawX: number + rawY: number + startX: number + startY: number + } | null = null + let lastTarget: { + wallNode: WallEvent['node'] + wallId: string + side: WindowNode['side'] + itemRotation: number + cursorRotation: number + clampedX: number + clampedY: number + valid: boolean + event: WallEvent + } | null = null const markWallDirty = (wallId: string | null) => { if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) @@ -140,7 +158,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } - const onWallEnter = (event: WallEvent) => { + const resolveMoveTarget = (event: WallEvent) => { if (!isValidWallSideFace(event.normal)) return if (isCurvedWall(event.node)) { hideCursor() @@ -153,40 +171,35 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + const rawLocalX = event.localPosition[0] + const rawLocalY = event.localPosition[1] + if (!dragAnchor || dragAnchor.wallId !== event.node.id) { + dragAnchor = { + wallId: event.node.id, + rawX: rawLocalX, + rawY: rawLocalY, + startX: event.node.id === original.parentId ? original.position[0] : rawLocalX, + startY: + event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY), + } + } + const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) + const targetLocalY = snapToHalf(dragAnchor.startY + (rawLocalY - dragAnchor.rawY)) const localX = resolveWallSlideAlignment({ wallNode: event.node, - rawLocalX: event.localPosition[0], + rawLocalX: targetLocalX, width: movingWindowNode.width, candidates: alignmentCandidates, bypass: event.nativeEvent?.altKey === true, }) - const localY = snapToHalf(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, localX, - localY, + targetLocalY, movingWindowNode.width, movingWindowNode.height, ) - const prevWallId = currentWallId - currentWallId = event.node.id - - useScene.getState().updateNode(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - useLiveTransforms.getState().set(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: itemRotation, - }) - - if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) - markWallDirtyThrottled(event.node.id) - const valid = !hasWallChildOverlap( event.node.id, clampedX, @@ -196,17 +209,62 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode movingWindowNode.id, ) + return { + wallNode: event.node, + wallId: event.node.id, + side, + itemRotation, + cursorRotation, + clampedX, + clampedY, + valid, + event, + } + } + + const applyPreview = (target: NonNullable) => { + if (currentWallId !== target.wallId) { + useScene.getState().updateNode(movingWindowNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, + }) + markWallDirty(currentWallId) + currentWallId = target.wallId + } else { + const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId) + if (windowMesh) { + windowMesh.position.set(target.clampedX, target.clampedY, 0) + windowMesh.rotation.set(0, target.itemRotation, 0) + windowMesh.updateMatrixWorld(true) + } + } + useLiveTransforms.getState().set(movingWindowNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: target.itemRotation, + }) + markWallDirtyThrottled(target.wallId) + updateCursor( wallLocalToWorld( - event.node, - clampedX, - clampedY, + target.wallNode, + target.clampedX, + target.clampedY, getLevelYOffset(), - getSlabElevation(event), + getSlabElevation(target.event), ), - cursorRotation, - valid, + target.cursorRotation, + target.valid, ) + } + + const onWallEnter = (event: WallEvent) => { + const target = resolveMoveTarget(event) + if (!target) return + lastTarget = target + applyPreview(target) event.stopPropagation() } @@ -219,73 +277,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Only interact with walls on the current level if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: movingWindowNode.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, - }) - const localY = snapToHalf(event.localPosition[1]) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - localY, - movingWindowNode.width, - movingWindowNode.height, - ) - - if (currentWallId !== event.node.id) { - // Wall changed mid-move: must updateNode to reparent - useScene.getState().updateNode(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - }) - markWallDirty(currentWallId) - currentWallId = event.node.id - } else { - // Same wall: update Three.js mesh directly to avoid store churn - // collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions - const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId) - if (windowMesh) { - windowMesh.position.set(clampedX, clampedY, 0) - windowMesh.rotation.set(0, itemRotation, 0) - windowMesh.updateMatrixWorld(true) - } - } - useLiveTransforms.getState().set(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: itemRotation, - }) - markWallDirtyThrottled(event.node.id) - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingWindowNode.width, - movingWindowNode.height, - movingWindowNode.id, - ) - - updateCursor( - wallLocalToWorld( - event.node, - clampedX, - clampedY, - getLevelYOffset(), - getSlabElevation(event), - ), - cursorRotation, - valid, - ) + const target = resolveMoveTarget(event) + if (!target) return + lastTarget = target + applyPreview(target) event.stopPropagation() } @@ -295,34 +290,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Only interact with walls on the current level if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: movingWindowNode.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true, - }) - const localY = snapToHalf(event.localPosition[1]) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - localY, - movingWindowNode.width, - movingWindowNode.height, - ) - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - movingWindowNode.width, - movingWindowNode.height, - movingWindowNode.id, - ) - if (!valid) return + const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) + if (!target?.valid) return let placedId: string @@ -341,13 +310,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const node = WindowNode.parse({ ...cloned, - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - wallId: event.node.id, - parentId: event.node.id, + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + wallId: target.wallId, + parentId: target.wallId, }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) + useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id } else { // Move mode: restore original (clean baseline) + resume + updateNode @@ -363,21 +332,21 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useScene.temporal.getState().resume() useScene.getState().updateNode(movingWindowNode.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, metadata: {}, }) - if (original.parentId && original.parentId !== event.node.id) { + if (original.parentId && original.parentId !== target.wallId) { markWallDirty(original.parentId) } placedId = movingWindowNode.id } - markWallDirty(event.node.id) + markWallDirty(target.wallId) useLiveTransforms.getState().clear(movingWindowNode.id) useScene.temporal.getState().pause() @@ -391,6 +360,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onWallLeave = () => { hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) + dragAnchor = null + lastTarget = null if (isNew) return // No original to restore for duplicates // Move mode: restore to original position while off-wall if (currentWallId && currentWallId !== original.parentId) { From 6b8dc33b62a172594aed390a85f16bc10d6fc3cc Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 7 Jun 2026 23:58:49 -0400 Subject: [PATCH 2/8] fix: restore walkthrough collisions and spawn controls --- .../editor/custom-camera-controls.tsx | 157 +++++++++++--- .../first-person/build-collider-world.test.ts | 108 ++++++++++ .../first-person/build-collider-world.ts | 199 +++++++++++------- .../nodes/src/spawn/__tests__/parity.test.ts | 64 +++++- packages/nodes/src/spawn/definition.ts | 31 ++- .../nodes/src/spawn/floorplan-affordances.ts | 35 +++ packages/nodes/src/spawn/floorplan.ts | 91 +++++--- packages/nodes/src/spawn/renderer.tsx | 6 +- packages/nodes/src/spawn/tool.tsx | 2 +- 9 files changed, 552 insertions(+), 141 deletions(-) create mode 100644 packages/editor/src/components/editor/first-person/build-collider-world.test.ts create mode 100644 packages/nodes/src/spawn/floorplan-affordances.ts diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 38091ba7..1fe75c4c 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -10,7 +10,7 @@ import { } from '@pascal-app/core' import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' -import { useThree } from '@react-three/fiber' +import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' import { Box3, Vector3 } from 'three' import { EDITOR_LAYER } from '../../lib/constants' @@ -25,13 +25,119 @@ const tempSize = new Vector3() const tempTarget = new Vector3() const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 +type CameraMode = ReturnType['cameraMode'] +type CameraPoseSnapshot = { + mode: CameraMode + position: [number, number, number] + target: [number, number, number] +} + +function writeVectorTuple(tuple: [number, number, number], vector: Vector3) { + tuple[0] = vector.x + tuple[1] = vector.y + tuple[2] = vector.z +} + +function saveCameraPose( + control: CameraControlsImpl, + mode: CameraMode, + pose: CameraPoseSnapshot, + position: Vector3, + target: Vector3, +) { + control.getPosition(position) + control.getTarget(target) + pose.mode = mode + writeVectorTuple(pose.position, position) + writeVectorTuple(pose.target, target) +} + +function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot) { + control.setLookAt( + pose.position[0], + pose.position[1], + pose.position[2], + pose.target[0], + pose.target[1], + pose.target[2], + false, + ) +} + +function useFirstPersonCameraPoseRestore( + controls: { current: CameraControlsImpl | null }, + isFirstPersonMode: boolean, + cameraMode: CameraMode, +) { + const restorePose = useRef({ + mode: cameraMode, + position: [0, 0, 0], + target: [0, 0, 0], + }) + const hasRestorePose = useRef(false) + const isRestoring = useRef(false) + const wasFirstPersonMode = useRef(isFirstPersonMode) + const snapshotPosition = useRef(new Vector3()) + const snapshotTarget = useRef(new Vector3()) + + useFrame(() => { + if (isFirstPersonMode || isRestoring.current) return + const control = controls.current + if (!control) return + + saveCameraPose( + control, + cameraMode, + restorePose.current, + snapshotPosition.current, + snapshotTarget.current, + ) + hasRestorePose.current = true + }) + + useEffect(() => { + const wasFirstPerson = wasFirstPersonMode.current + wasFirstPersonMode.current = isFirstPersonMode + + if (isFirstPersonMode) { + return + } + + if (!wasFirstPerson || !hasRestorePose.current) return + + const pose = restorePose.current + isRestoring.current = true + useViewer.getState().setCameraMode(pose.mode) + + const restoreFrame = requestAnimationFrame(() => { + const currentControls = controls.current + if (currentControls) { + restoreCameraPose(currentControls, pose) + } + isRestoring.current = false + }) + + return () => { + cancelAnimationFrame(restoreFrame) + isRestoring.current = false + } + }, [controls, isFirstPersonMode]) + + return useCallback(() => isRestoring.current, []) +} export const CustomCameraControls = () => { - const controls = useRef(null!) + const controls = useRef(null) const isPreviewMode = useEditor((s) => s.isPreviewMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const selection = useViewer((s) => s.selection) + const cameraMode = useViewer((state) => state.cameraMode) + const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore( + controls, + isFirstPersonMode, + cameraMode, + ) const currentLevelId = selection.levelId const firstLoad = useRef(true) const maxPolarAngle = @@ -47,7 +153,7 @@ export const CustomCameraControls = () => { }, [camera, raycaster]) useEffect(() => { - if (isPreviewMode) return // Preview mode uses auto-navigate instead + if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return let targetY = 0 if (currentLevelId) { const levelMesh = sceneRegistry.nodes.get(currentLevelId) @@ -62,10 +168,10 @@ export const CustomCameraControls = () => { } controls.current.getTarget(currentTarget) controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true) - }, [currentLevelId, isPreviewMode]) + }, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose]) useEffect(() => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return controls.current.maxPolarAngle = maxPolarAngle controls.current.minPolarAngle = 0 @@ -73,11 +179,11 @@ export const CustomCameraControls = () => { if (controls.current.polarAngle > maxPolarAngle) { controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true) } - }, [maxPolarAngle]) + }, [isFirstPersonMode, maxPolarAngle]) const focusNode = useCallback( (nodeId: string) => { - if (isPreviewMode || !controls.current) return + if (isPreviewMode || isFirstPersonMode || !controls.current) return const object3D = sceneRegistry.nodes.get(nodeId) if (!object3D) return @@ -100,11 +206,10 @@ export const CustomCameraControls = () => { true, ) }, - [isPreviewMode], + [isPreviewMode, isFirstPersonMode], ) // Configure mouse buttons based on control mode and camera mode - const cameraMode = useViewer((state) => state.cameraMode) const mouseButtons = useMemo(() => { // Use ZOOM for orthographic camera, DOLLY for perspective camera const wheelAction = @@ -170,6 +275,8 @@ export const CustomCameraControls = () => { }, [cameraMode, isPreviewMode, isInteracting]) useEffect(() => { + if (isFirstPersonMode) return + const keyState = { shiftRight: false, shiftLeft: false, @@ -249,8 +356,9 @@ export const CustomCameraControls = () => { return () => { document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keyup', onKeyUp) + document.body.style.cursor = '' } - }, [cameraMode, isPreviewMode]) + }, [cameraMode, isPreviewMode, isFirstPersonMode]) // Preview mode: auto-navigate camera to selected node (viewer behavior) const previewTargetNodeId = isPreviewMode @@ -258,7 +366,7 @@ export const CustomCameraControls = () => { : null useEffect(() => { - if (!(isPreviewMode && controls.current)) return + if (!(isPreviewMode && controls.current) || isFirstPersonMode) return const nodes = useScene.getState().nodes let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null @@ -318,7 +426,7 @@ export const CustomCameraControls = () => { tempCenter.z, true, ) - }, [isPreviewMode, previewTargetNodeId]) + }, [isPreviewMode, isFirstPersonMode, previewTargetNodeId]) // Preset capture auto-framing — when `setCaptureMode({ mode: 'preset', // isolated })` fires, fly the camera to a pose that fits the union @@ -329,6 +437,7 @@ export const CustomCameraControls = () => { // modal opened. const captureMode = useEditor((s) => s.captureMode) useEffect(() => { + if (isFirstPersonMode) return if (!controls.current) return if (captureMode.mode !== 'preset') return const ids = captureMode.isolated @@ -417,11 +526,11 @@ export const CustomCameraControls = () => { true, ) } - }, [captureMode]) + }, [captureMode, isFirstPersonMode]) useEffect(() => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const position = new Vector3() const target = new Vector3() @@ -439,7 +548,7 @@ export const CustomCameraControls = () => { }) } const handleNodeView = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const node = useScene.getState().nodes[nodeId] if (!node?.camera) return @@ -457,7 +566,7 @@ export const CustomCameraControls = () => { } const handleTopView = () => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const currentPolarAngle = controls.current.polarAngle @@ -469,7 +578,7 @@ export const CustomCameraControls = () => { } const handleOrbitCW = () => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const currentAzimuth = controls.current.azimuthAngle const currentPolar = controls.current.polarAngle @@ -481,7 +590,7 @@ export const CustomCameraControls = () => { } const handleOrbitCCW = () => { - if (!controls.current) return + if (isFirstPersonMode || !controls.current) return const currentAzimuth = controls.current.azimuthAngle const currentPolar = controls.current.polarAngle @@ -497,7 +606,7 @@ export const CustomCameraControls = () => { } const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => { - if (!controls.current || isPreviewMode) return + if (isFirstPersonMode || !controls.current || isPreviewMode) return if (!bounds) { // Restore default framing pose when no bounds were computed. controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) @@ -530,7 +639,7 @@ export const CustomCameraControls = () => { emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) emitter.off('camera-controls:fit-scene', handleFitScene) } - }, [focusNode, isPreviewMode]) + }, [focusNode, isPreviewMode, isFirstPersonMode]) const onTransitionStart = useCallback(() => { useViewer.getState().setCameraDragging(true) @@ -540,10 +649,6 @@ export const CustomCameraControls = () => { useViewer.getState().setCameraDragging(false) }, []) - if (isFirstPersonMode) { - return null - } - // Preset capture mode frames a single subtree (often a 0.3–2m preset), // so the default 6m minDistance prevents the user from getting close // enough to compose a good thumbnail. Relax the clamp to 0.5m while @@ -552,6 +657,10 @@ export const CustomCameraControls = () => { const isPresetCapture = captureMode.mode === 'preset' const minDistance = isPresetCapture ? 0.5 : 6 + if (isFirstPersonMode) { + return null + } + return ( [node.id, node])), + rootNodeIds: nodes.map((node) => node.id), + } as never) +} + +describe('buildFirstPersonColliderWorldFromRegistry', () => { + afterEach(() => { + sceneRegistry.clear() + nodeRegistry._reset() + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) + }) + + test('includes structure and furnish nodes discovered through the node registry', () => { + registerColliderDefinition('column', ColumnNode, 'structure') + registerColliderDefinition('shelf', ShelfNode, 'furnish') + + const column = ColumnNode.parse({ id: 'column_test' }) + const shelf = ShelfNode.parse({ id: 'shelf_test', position: [3, 0, 0] }) + setSceneNodes([column, shelf]) + mountNode(column, [1, 2, 1], [0, 1, 0]) + mountNode(shelf, [2, 1, 1], [3, 0.5, 0]) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + expect(world?.bounds?.min.x).toBeCloseTo(-0.5) + expect(world?.bounds?.max.x).toBeCloseTo(4) + world?.dispose() + }) + + test('leaves elevators to their dedicated dynamic collider meshes', () => { + registerColliderDefinition('elevator', ElevatorNode, 'structure') + + const elevator = ElevatorNode.parse({ id: 'elevator_test' }) + setSceneNodes([elevator]) + mountNode(elevator, [2, 3, 2], [0, 1.5, 0]) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).toBeNull() + }) + + test('adds a fallback floor for a visible level with no slab', () => { + const level = LevelNode.parse({ id: 'level_test', level: 0 }) + setSceneNodes([level]) + mountRegistryGroup(level) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + expect(world?.bounds?.min.y).toBeCloseTo(-0.08) + expect(world?.bounds?.max.y).toBeCloseTo(0) + world?.dispose() + }) +}) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index ff039020..478336c0 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -1,8 +1,10 @@ import { + type AnyNode, type AnyNodeId, type DoorNode, getGarageVisibleOpeningRatio, isOperationDoorType, + nodeRegistry, sceneRegistry, useInteractive, useScene, @@ -10,21 +12,11 @@ import { import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' - -const COLLIDER_NODE_TYPES = [ - 'wall', - 'fence', - 'slab', - 'stair', - 'stair-segment', - 'roof', - 'roof-segment', - 'door', - 'window', - 'item', -] as const +import { computeSceneBoundsXZ } from '../../../lib/scene-bounds' const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh']) +const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish']) +const DEDICATED_COLLIDER_NODE_TYPES = new Set(['elevator']) const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial() const DOWN = new THREE.Vector3(0, -1, 0) const UP = new THREE.Vector3(0, 1, 0) @@ -32,6 +24,9 @@ const SPAWN_EYE_HEIGHT = 1.65 const RAYCAST_CLEARANCE = 25 const DOOR_LEAF_COLLIDER_DEPTH = 0.06 const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85 +const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08 +const LEVEL_FALLBACK_FLOOR_PADDING = 2 +const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30 export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT @@ -46,7 +41,8 @@ export type FirstPersonSpawn = { yaw: number } -type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number] +type LevelNode = Extract +type SceneNodes = ReturnType['nodes'] function isMesh(object: THREE.Object3D): object is THREE.Mesh { return 'isMesh' in object && (object as THREE.Mesh).isMesh @@ -56,6 +52,72 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible } +function isGenericColliderNode(node: AnyNode) { + if (node.visible === false) return false + if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) return false + return COLLIDER_NODE_CATEGORIES.has(nodeRegistry.get(node.type)?.category ?? '') +} + +function createBoxColliderGeometry(width: number, height: number, depth: number) { + const sourceGeometry = new THREE.BoxGeometry(width, height, depth).toNonIndexed() + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) + geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) + sourceGeometry.dispose() + return geometry +} + +function getVisibleLevelChildren(level: LevelNode, nodes: SceneNodes) { + return level.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((child): child is AnyNode => Boolean(child && child.visible !== false)) +} + +function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) { + if (level.visible === false) return null + + const children = getVisibleLevelChildren(level, nodes) + if (children.some((child) => child.type === 'slab')) return null + + const levelObject = sceneRegistry.nodes.get(level.id) + if (!levelObject?.visible) return null + + const bounds = computeSceneBoundsXZ(children) + const [centerX, centerZ] = bounds?.center ?? [0, 0] + const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0] + const width = Math.max( + boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + LEVEL_FALLBACK_FLOOR_MIN_SIZE, + ) + const depth = Math.max( + boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + LEVEL_FALLBACK_FLOOR_MIN_SIZE, + ) + + const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth) + + levelObject.updateWorldMatrix(true, false) + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ), + ) + geometry.applyMatrix4(levelObject.matrixWorld) + return geometry +} + +function collectLevelFallbackFloorGeometries(nodes: SceneNodes) { + const geometries: THREE.BufferGeometry[] = [] + + for (const levelId of sceneRegistry.byType.level!) { + const node = nodes[levelId as AnyNodeId] + if (node?.type !== 'level') continue + + const geometry = createLevelFallbackFloorGeometry(node, nodes) + if (geometry) geometries.push(geometry) + } + + return geometries +} + // Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a // plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every // merged geometry to share the same typed-array constructor for matching attributes, so @@ -107,16 +169,12 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { return cleanGeometry } -function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) { - if (type === 'window') { - const node = useScene.getState().nodes[nodeId as AnyNodeId] - return node?.type === 'window' && node.openingKind === 'opening' +function shouldSkipColliderNode(node: AnyNode) { + if (node.type === 'window') { + return node.openingKind === 'opening' } - if (type !== 'door') return false - - const node = useScene.getState().nodes[nodeId as AnyNodeId] - if (!node || node.type !== 'door') return false + if (node.type !== 'door') return false if (!node.segments.length) return true @@ -145,15 +203,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { const visibleHeight = leafH * (1 - openAmount) if (visibleHeight <= 0.12) return null - const sourceGeometry = new THREE.BoxGeometry( - leafW, - visibleHeight, - DOOR_LEAF_COLLIDER_DEPTH, - ).toNonIndexed() - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) - geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) - sourceGeometry.dispose() + const geometry = createBoxColliderGeometry(leafW, visibleHeight, DOOR_LEAF_COLLIDER_DEPTH) const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2 geometry.applyMatrix4( root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)), @@ -174,15 +224,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0)) const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign - const sourceGeometry = new THREE.BoxGeometry( - leafW, - leafH, - DOOR_LEAF_COLLIDER_DEPTH, - ).toNonIndexed() - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone()) - geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone()) - sourceGeometry.dispose() + const geometry = createBoxColliderGeometry(leafW, leafH, DOOR_LEAF_COLLIDER_DEPTH) const matrix = root.matrixWorld .clone() .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0)) @@ -193,16 +235,17 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { return geometry } -function buildRegisteredNodeTypeLookup() { - const nodeTypes = new Map() +function buildRegisteredColliderNodeIds(nodes: SceneNodes) { + const nodeIds = new Set() - for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]!) { - nodeTypes.set(nodeId, type) - } + for (const nodeId of sceneRegistry.nodes.keys()) { + const node = nodes[nodeId as AnyNodeId] + if (!node || !isGenericColliderNode(node)) continue + if (shouldSkipColliderNode(node)) continue + nodeIds.add(nodeId) } - return nodeTypes + return nodeIds } function collectColliderGeometriesFromNode( @@ -210,7 +253,7 @@ function collectColliderGeometriesFromNode( rootNodeId: string, visitedMeshes: WeakSet, registeredObjectIds: Map, - registeredNodeTypes: Map, + registeredColliderNodeIds: Set, ): THREE.BufferGeometry[] { const geometries: THREE.BufferGeometry[] = [] @@ -232,11 +275,8 @@ function collectColliderGeometriesFromNode( for (const child of object.children) { const childNodeId = registeredObjectIds.get(child) - if (childNodeId && childNodeId !== rootNodeId) { - const childType = registeredNodeTypes.get(childNodeId) - if (childType && COLLIDER_NODE_TYPES.includes(childType)) { - continue - } + if (childNodeId && childNodeId !== rootNodeId && registeredColliderNodeIds.has(childNodeId)) { + continue } visit(child) @@ -249,46 +289,45 @@ function collectColliderGeometriesFromNode( } export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null { + const nodes = useScene.getState().nodes const geometries: THREE.BufferGeometry[] = [] const visitedMeshes = new WeakSet() - const registeredNodeTypes = buildRegisteredNodeTypeLookup() + const registeredColliderNodeIds = buildRegisteredColliderNodeIds(nodes) const registeredObjectIds = new Map() for (const [nodeId, object] of sceneRegistry.nodes) { registeredObjectIds.set(object, nodeId) } - for (const type of COLLIDER_NODE_TYPES) { - for (const nodeId of sceneRegistry.byType[type]!) { - if (shouldSkipColliderNode(nodeId, type)) continue + for (const nodeId of registeredColliderNodeIds) { + const node = nodes[nodeId as AnyNodeId] + if (!node) continue - const root = sceneRegistry.nodes.get(nodeId) - if (!root) continue + const root = sceneRegistry.nodes.get(nodeId) + if (!root) continue - if (type === 'door') { - const node = useScene.getState().nodes[nodeId as AnyNodeId] - if (node?.type !== 'door') continue - - const doorGeometry = createDoorLeafColliderGeometry(root, node) - if (doorGeometry) { - geometries.push(doorGeometry) - } - continue + if (node.type === 'door') { + const doorGeometry = createDoorLeafColliderGeometry(root, node) + if (doorGeometry) { + geometries.push(doorGeometry) } - - root.updateMatrixWorld(true) - geometries.push( - ...collectColliderGeometriesFromNode( - root, - nodeId, - visitedMeshes, - registeredObjectIds, - registeredNodeTypes, - ), - ) + continue } + + root.updateMatrixWorld(true) + geometries.push( + ...collectColliderGeometriesFromNode( + root, + nodeId, + visitedMeshes, + registeredObjectIds, + registeredColliderNodeIds, + ), + ) } + geometries.push(...collectLevelFallbackFloorGeometries(nodes)) + if (geometries.length === 0) { return null } @@ -311,7 +350,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree bvhGeometry.computeBoundsTree?.({ - maxLeafTris: 12, + maxLeafSize: 12, strategy: 0, } as never) bvhGeometry.computeBoundingBox() diff --git a/packages/nodes/src/spawn/__tests__/parity.test.ts b/packages/nodes/src/spawn/__tests__/parity.test.ts index 67f85c3e..3c1d8d01 100644 --- a/packages/nodes/src/spawn/__tests__/parity.test.ts +++ b/packages/nodes/src/spawn/__tests__/parity.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from 'bun:test' -import { SpawnNode as SpawnSchemaFromCore } from '@pascal-app/core' +import { + type FloorplanGeometry, + type GeometryContext, + SpawnNode as SpawnSchemaFromCore, +} from '@pascal-app/core' import { spawnDefinition } from '../definition' +import { buildSpawnFloorplan } from '../floorplan' import { SpawnNode } from '../schema' /** @@ -8,7 +13,7 @@ import { SpawnNode } from '../schema' * * The new renderer is a near-line-by-line port of the legacy * `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — - * same mesh count, same primitives, same colors. The "parity" assertion + * same mesh count and primitives. The "parity" assertion * for the spike is structural (definition is well-formed, both lazy * modules resolve to React components) plus a manual visual eyeball check * documented in the plan. Pixel-level Playwright parity lands in Phase 4 @@ -52,6 +57,56 @@ describe('spawn definition', () => { expect(angles).toContain(0) }) + test('handles expose rotation and move controls', () => { + expect(Array.isArray(spawnDefinition.handles)).toBe(true) + if (!Array.isArray(spawnDefinition.handles)) return + expect(spawnDefinition.handles.map((handle) => handle.kind)).toEqual([ + 'arc-resize', + 'translate', + ]) + }) + + test('floorplan uses indigo marker color and selected rotation affordance', () => { + const spawn = SpawnNode.parse({ + id: 'spawn_test1234567890ab', + position: [1, 0, 2], + rotation: Math.PI / 4, + }) + const geometry = buildSpawnFloorplan(spawn, { + resolve: () => undefined, + children: [], + siblings: [], + parent: null, + viewState: { + selected: true, + highlighted: false, + hovered: false, + moving: false, + palette: { + selectedStroke: '#60a5fa', + selectedFill: '#dbeafe', + selectedHatch: '#60a5fa', + wallHoverStroke: '#60a5fa', + endpointHandleFill: '#fed7aa', + endpointHandleStroke: '#f97316', + endpointHandleHoverStroke: '#fb923c', + endpointHandleActiveFill: '#fdba74', + endpointHandleActiveStroke: '#ea580c', + curveHandleFill: '#99f6e4', + curveHandleStroke: '#14b8a6', + curveHandleHoverStroke: '#2dd4bf', + measurementStroke: '#6366f1', + measurementLabelBackground: '#ffffff', + measurementLabelText: '#111827', + }, + }, + } satisfies GeometryContext) + + const flat = flattenFloorplan(geometry) + expect(flat.some((entry) => entry.kind === 'polygon' && entry.fill === '#818cf8')).toBe(true) + expect(flat.some((entry) => entry.kind === 'rotate-arrow')).toBe(true) + }) + test('renderer is a parametric lazy module reference', () => { expect(spawnDefinition.renderer.kind).toBe('parametric') if (spawnDefinition.renderer.kind !== 'parametric') return @@ -67,3 +122,8 @@ describe('spawn definition', () => { expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0) }) }) + +function flattenFloorplan(geometry: FloorplanGeometry): FloorplanGeometry[] { + if (geometry.kind !== 'group') return [geometry] + return geometry.children.flatMap((child) => flattenFloorplan(child)) +} diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 0f7109df..f942b893 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -1,10 +1,36 @@ import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core' import { buildSpawnFloorplan } from './floorplan' +import { spawnRotateAffordance } from './floorplan-affordances' import { spawnParametrics } from './parametrics' import { SpawnNode } from './schema' const SPAWN_FOOTPRINT = 0.6 +const SPAWN_HANDLE_HEIGHT = 0.46 const MOVE_FRONT_OFFSET = 0.35 +const ROTATE_CORNER_OFFSET = 0.32 +const ROTATE_RING_OFFSET = 0.04 + +function spawnRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: () => [ + SPAWN_FOOTPRINT / 2, + SPAWN_HANDLE_HEIGHT, + SPAWN_FOOTPRINT / 2 + ROTATE_CORNER_OFFSET, + ], + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: () => Math.hypot(SPAWN_FOOTPRINT / 2, SPAWN_FOOTPRINT / 2) + ROTATE_RING_OFFSET, + y: () => SPAWN_HANDLE_HEIGHT, + }, + } +} function spawnMoveHandle(): HandleDescriptor { return { @@ -52,7 +78,7 @@ export const spawnDefinition: NodeDefinition = { }, parametrics: spawnParametrics, - handles: [spawnMoveHandle()], + handles: [spawnRotateHandle(), spawnMoveHandle()], renderer: { kind: 'parametric', @@ -66,6 +92,9 @@ export const spawnDefinition: NodeDefinition = { // delete. Legacy spawn click handlers in FloorplanNodeLayer become // dead code once Phase 6 cleanup removes the [] entries path. floorplan: buildSpawnFloorplan, + floorplanAffordances: { + 'spawn-rotate': spawnRotateAffordance, + }, tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place spawn point' }, diff --git a/packages/nodes/src/spawn/floorplan-affordances.ts b/packages/nodes/src/spawn/floorplan-affordances.ts new file mode 100644 index 00000000..b8482fb2 --- /dev/null +++ b/packages/nodes/src/spawn/floorplan-affordances.ts @@ -0,0 +1,35 @@ +import { + type AnyNodeId, + type FloorplanAffordance, + type SpawnNode, + useScene, +} from '@pascal-app/core' + +export const spawnRotateAffordance: FloorplanAffordance = { + start({ node, initialPlanPoint }) { + const spawnId = node.id as AnyNodeId + const initialRotation = node.rotation ?? 0 + const cx = node.position[0] + const cz = node.position[2] + const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx) + let lastRotation = initialRotation + + return { + affectedIds: [spawnId], + apply({ planPoint }) { + const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx) + let delta = currentAngle - initialAngle + while (delta > Math.PI) delta -= 2 * Math.PI + while (delta < -Math.PI) delta += 2 * Math.PI + lastRotation = initialRotation - delta + useScene.getState().updateNode(spawnId, { rotation: lastRotation }) + }, + canCommit() { + return true + }, + commit() { + useScene.getState().updateNode(spawnId, { rotation: lastRotation }) + }, + } + }, +} diff --git a/packages/nodes/src/spawn/floorplan.ts b/packages/nodes/src/spawn/floorplan.ts index 4e219462..2922369a 100644 --- a/packages/nodes/src/spawn/floorplan.ts +++ b/packages/nodes/src/spawn/floorplan.ts @@ -1,48 +1,79 @@ -import type { FloorplanGeometry } from '@pascal-app/core' +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' import type { SpawnNode } from './schema' +const SPAWN_COLOR = '#818cf8' +const ROTATE_ARROW_CORNER_OFFSET = 0.22 + /** * 2D floor-plan marker for a spawn point. A small filled circle at the * spawn's position, with a triangular arrow indicating the facing * direction (rotation around Y, looking down at the X-Z plane). * - * Color matches the 3D renderer's `SPAWN_COLOR = '#22c55e'` so the user + * Color matches the 3D renderer's indigo spawn material so the user * sees the same visual identity in both views. * * Coordinates are level-local meters; rotation is radians. */ -export function buildSpawnFloorplan(node: SpawnNode): FloorplanGeometry { +export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): FloorplanGeometry { const [px, , pz] = node.position const ry = node.rotation + const isSelected = ctx.viewState?.selected ?? false + + const children: FloorplanGeometry[] = [ + { + kind: 'group', + transform: { translate: [px, pz], rotate: ry }, + children: [ + // Direction-pointing triangle, base centered at origin, tip in -Z + // (forward). Matches the 3D arrow's orientation. + { + kind: 'polygon', + points: [ + [0, -0.28], + [-0.18, 0.12], + [0.18, 0.12], + ], + fill: SPAWN_COLOR, + opacity: 0.85, + }, + // Spawn body marker — circle outline so the spawn is legible at + // small zoom levels where the triangle would shrink past visibility. + { + kind: 'circle', + cx: 0, + cy: 0, + r: 0.34, + stroke: SPAWN_COLOR, + strokeWidth: 0.025, + fill: SPAWN_COLOR, + opacity: 0.18, + }, + ], + }, + ] + + if (isSelected) { + const cornerLocalX = 0.34 + ROTATE_ARROW_CORNER_OFFSET + const cornerLocalZ = 0.34 + ROTATE_ARROW_CORNER_OFFSET + const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, ry) + const [radialX, radialZ] = rotatePlanVector(1, 1, ry) + children.push({ + kind: 'rotate-arrow', + point: [px + cornerX, pz + cornerZ], + angle: Math.atan2(radialZ, radialX), + affordance: 'spawn-rotate', + pivot: [px, pz], + }) + } return { kind: 'group', - transform: { translate: [px, pz], rotate: ry }, - children: [ - // Direction-pointing triangle, base centered at origin, tip in -Z - // (forward). Matches the 3D arrow's orientation. - { - kind: 'polygon', - points: [ - [0, -0.28], - [-0.18, 0.12], - [0.18, 0.12], - ], - fill: '#22c55e', - opacity: 0.85, - }, - // Spawn body marker — circle outline so the spawn is legible at - // small zoom levels where the triangle would shrink past visibility. - { - kind: 'circle', - cx: 0, - cy: 0, - r: 0.34, - stroke: '#22c55e', - strokeWidth: 0.025, - fill: '#22c55e', - opacity: 0.18, - }, - ], + children, } } + +function rotatePlanVector(x: number, y: number, rotation: number): FloorplanPoint { + const c = Math.cos(rotation) + const s = Math.sin(rotation) + return [x * c - y * s, x * s + y * c] +} diff --git a/packages/nodes/src/spawn/renderer.tsx b/packages/nodes/src/spawn/renderer.tsx index 9088ac87..4203655d 100644 --- a/packages/nodes/src/spawn/renderer.tsx +++ b/packages/nodes/src/spawn/renderer.tsx @@ -11,12 +11,12 @@ import { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/vie import { useMemo, useRef } from 'react' import { Color, type Group, Shape } from 'three' -const SPAWN_COLOR = new Color('#22c55e') +const SPAWN_COLOR = new Color('#818cf8') /** * Registry-driven spawn renderer. Behaviorally identical to the legacy * `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — same - * geometry, same colors, same event surface. When the spawn definition lands + * geometry and event surface. When the spawn definition lands * in `builtinPlugin.nodes`, the Phase 0 dispatch shims switch the renderer * here and the legacy one is short-circuited. * @@ -38,7 +38,7 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => { useRegistry(node.id, 'spawn', ref) const material = useMemo(() => { - const next = createDefaultMaterial('#22c55e', 0.42, shading) as ReturnType< + const next = createDefaultMaterial('#818cf8', 0.42, shading) as ReturnType< typeof createDefaultMaterial > & { emissive?: Color diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 6bb9dc00..fc7b7c8e 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -120,7 +120,7 @@ const SpawnTool = () => { if (!activeLevelId) return null - return + return } export default SpawnTool From ab271df9b6987ab9382d99c06880d4e4d00205ff Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 8 Jun 2026 01:05:30 -0400 Subject: [PATCH 3/8] fix(editor): keep walkthrough on ground, pass through ceilings Two gaps in the first-person walkthrough collider world: - The visible default ground is the site node's mesh, but `site` is a `site`-category node and excluded from the generic collider sweep. The per-level fallback floor only fires for a level without a slab, so a spawn on the bare ground (no slab) had no floor and fell through. Add a dedicated site-ground collider derived from node data (not the rendered mesh, so it's immune to geometry-mount timing) covering the scene footprint at the site's ground plane. - Ceilings are `structure`-category and were swept in as colliders, so the player was held up as if standing on a floor slab. Exclude nodes whose registry `surfaceRole` is 'ceiling' so the player passes through them (they're a transparent mount surface for lights/fans), while walls, slabs and stairs keep colliding. Adds tests for both behaviors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../first-person/build-collider-world.test.ts | 41 +++++++++++++ .../first-person/build-collider-world.ts | 58 ++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index fd5214e6..c76d50f1 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -2,12 +2,14 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeDefinition, + CeilingNode, ColumnNode, ElevatorNode, LevelNode, nodeRegistry, registerNode, ShelfNode, + SiteNode, sceneRegistry, useScene, } from '@pascal-app/core' @@ -18,12 +20,14 @@ function registerColliderDefinition( kind: AnyNode['type'], schema: AnyNodeDefinition['schema'], category: AnyNodeDefinition['category'], + surfaceRole?: AnyNodeDefinition['surfaceRole'], ) { registerNode({ kind, schema, schemaVersion: 1, category, + surfaceRole, capabilities: {}, } as AnyNodeDefinition) } @@ -81,6 +85,26 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { world?.dispose() }) + test('excludes ceiling surfaces so the walkthrough player passes through them', () => { + registerColliderDefinition('column', ColumnNode, 'structure') + registerColliderDefinition('ceiling', CeilingNode, 'structure', 'ceiling') + + const column = ColumnNode.parse({ id: 'column_test' }) + const ceiling = CeilingNode.parse({ id: 'ceiling_test', polygon: [] }) + setSceneNodes([column, ceiling]) + mountNode(column, [1, 2, 1], [0, 1, 0]) + // A wide ceiling at head height — if it were collected, bounds would span ±5. + mountNode(ceiling, [10, 0.1, 10], [0, 2.5, 0]) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + // Bounds reflect only the 1×1 column; the ceiling contributed no geometry. + expect(world?.bounds?.min.x).toBeCloseTo(-0.5) + expect(world?.bounds?.max.x).toBeCloseTo(0.5) + world?.dispose() + }) + test('leaves elevators to their dedicated dynamic collider meshes', () => { registerColliderDefinition('elevator', ElevatorNode, 'structure') @@ -105,4 +129,21 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { expect(world?.bounds?.max.y).toBeCloseTo(0) world?.dispose() }) + + test('adds a site ground collider so a spawn on bare ground has a floor', () => { + const site = SiteNode.parse({ id: 'site_test' }) + setSceneNodes([site]) + mountRegistryGroup(site) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + // Ground slab sits just below the site ground plane (y = 0). + expect(world?.bounds?.min.y).toBeCloseTo(-0.08) + expect(world?.bounds?.max.y).toBeCloseTo(0) + // Default site footprint falls back to the 30 m minimum size. + expect(world?.bounds?.min.x).toBeCloseTo(-15) + expect(world?.bounds?.max.x).toBeCloseTo(15) + world?.dispose() + }) }) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 478336c0..666e3de9 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -42,6 +42,7 @@ export type FirstPersonSpawn = { } type LevelNode = Extract +type SiteNode = Extract type SceneNodes = ReturnType['nodes'] function isMesh(object: THREE.Object3D): object is THREE.Mesh { @@ -55,7 +56,12 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) function isGenericColliderNode(node: AnyNode) { if (node.visible === false) return false if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) return false - return COLLIDER_NODE_CATEGORIES.has(nodeRegistry.get(node.type)?.category ?? '') + const def = nodeRegistry.get(node.type) + // Ceilings are a transparent mount surface for fixtures (lights, fans), not a + // walkable or blocking structure — the walkthrough player must pass through + // them rather than be held up as if standing on a floor slab. + if (def?.surfaceRole === 'ceiling') return false + return COLLIDER_NODE_CATEGORIES.has(def?.category ?? '') } function createBoxColliderGeometry(width: number, height: number, depth: number) { @@ -118,6 +124,55 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) { return geometries } +// The visible ground is the site node's ground mesh, but `site` is a `site` +// category node and therefore excluded from the generic collider sweep. Without +// a dedicated collider, a spawn on the bare ground (no slab, or not parented to +// a level that triggers the per-level fallback) has no floor to stand on and the +// walkthrough player falls through. Derive a thin ground slab from node data (not +// the rendered mesh) so it exists regardless of geometry-mount timing, sized to +// cover the whole scene footprint at the site's ground plane. +function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) { + if (site.visible === false) return null + + const siteObject = sceneRegistry.nodes.get(site.id) + if (!siteObject?.visible) return null + + const bounds = computeSceneBoundsXZ(nodes) + const [centerX, centerZ] = bounds?.center ?? [0, 0] + const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0] + const width = Math.max( + boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + LEVEL_FALLBACK_FLOOR_MIN_SIZE, + ) + const depth = Math.max( + boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2, + LEVEL_FALLBACK_FLOOR_MIN_SIZE, + ) + + const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth) + + siteObject.updateWorldMatrix(true, false) + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ), + ) + geometry.applyMatrix4(siteObject.matrixWorld) + return geometry +} + +function collectSiteGroundColliderGeometries(nodes: SceneNodes) { + const geometries: THREE.BufferGeometry[] = [] + + for (const siteId of sceneRegistry.byType.site ?? []) { + const node = nodes[siteId as AnyNodeId] + if (node?.type !== 'site') continue + + const geometry = createSiteGroundColliderGeometry(node, nodes) + if (geometry) geometries.push(geometry) + } + + return geometries +} + // Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a // plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every // merged geometry to share the same typed-array constructor for matching attributes, so @@ -327,6 +382,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider } geometries.push(...collectLevelFallbackFloorGeometries(nodes)) + geometries.push(...collectSiteGroundColliderGeometries(nodes)) if (geometries.length === 0) { return null From 8dc602caa9b886a4ec3a760d4796cfa091014eca Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 8 Jun 2026 01:06:13 -0400 Subject: [PATCH 4/8] Improve editor manipulation flows --- packages/core/src/registry/types.ts | 4 +- .../src/services/alignment-anchors.test.ts | 66 ++ .../core/src/services/alignment-anchors.ts | 55 ++ packages/core/src/services/index.ts | 1 + .../use-scene-elevator-migration.test.ts | 123 +++ packages/core/src/store/use-scene.ts | 87 ++ .../floorplan-registry-move-overlay.tsx | 192 +++-- .../renderers/floorplan-geometry-renderer.tsx | 61 +- .../renderers/floorplan-registry-layer.tsx | 55 +- .../editor/custom-camera-controls.tsx | 259 +++++- .../src/components/editor/floorplan-panel.tsx | 785 ++++++++++++++++-- .../components/editor/group-move-handle.tsx | 84 +- .../components/editor/group-rotate-handle.tsx | 81 +- .../editor/group-transform-shared.test.ts | 205 +++++ .../editor/group-transform-shared.ts | 79 +- .../editor/handles/use-handle-drag.ts | 24 + .../editor/src/components/editor/index.tsx | 1 + .../components/editor/node-arrow-handles.tsx | 5 +- .../editor/slab-hole-highlights.tsx | 2 + .../editor/wall-move-side-handles.tsx | 5 + .../registry/move-registry-node-tool.tsx | 85 +- .../tools/select/box-select-state.ts | 55 ++ .../tools/select/box-select-tool.tsx | 111 +-- .../tools/select/plane-box-select-tool.tsx | 19 +- .../select/screen-rectangle-selection.ts | 84 ++ .../tools/select/select-candidates.test.ts | 109 +++ .../tools/select/select-candidates.ts | 104 +-- .../shared/fresh-placement-visibility.ts | 61 ++ .../src/components/tools/stair/stair-tool.tsx | 48 +- packages/editor/src/index.tsx | 13 + .../src/lib/fresh-planar-placement.test.ts | 102 +++ .../editor/src/lib/fresh-planar-placement.ts | 63 ++ packages/editor/src/lib/placement-metadata.ts | 29 + .../src/lib/planar-cursor-placement.test.ts | 43 + .../editor/src/lib/planar-cursor-placement.ts | 42 + packages/editor/src/lib/roof-duplication.ts | 2 +- packages/editor/src/lib/scene.ts | 8 +- packages/editor/src/store/use-editor.tsx | 22 + packages/nodes/src/column/floorplan-move.ts | 18 +- packages/nodes/src/column/move-tool.tsx | 81 +- packages/nodes/src/column/tool.tsx | 99 +-- packages/nodes/src/door/floorplan-move.ts | 21 +- packages/nodes/src/elevator/definition.ts | 48 +- packages/nodes/src/item/floorplan-move.ts | 117 ++- .../src/roof-segment/floorplan-affordances.ts | 22 +- .../nodes/src/shared/floor-placement.test.ts | 37 + packages/nodes/src/shared/floor-placement.ts | 125 +++ .../nodes/src/shared/floorplan-cursor.test.ts | 29 + packages/nodes/src/shared/floorplan-cursor.ts | 35 + packages/nodes/src/shared/move-roof-tool.tsx | 104 ++- .../nodes/src/shared/polygon-centroid-move.ts | 24 +- .../nodes/src/shared/wall-attach-target.ts | 11 + packages/nodes/src/shelf/floorplan-move.ts | 18 +- packages/nodes/src/shelf/tool.tsx | 158 +--- packages/nodes/src/stair/definition.ts | 4 +- packages/nodes/src/stair/floorplan-move.ts | 31 +- packages/nodes/src/window/floorplan-move.ts | 21 +- 57 files changed, 3442 insertions(+), 735 deletions(-) create mode 100644 packages/core/src/store/use-scene-elevator-migration.test.ts create mode 100644 packages/editor/src/components/editor/group-transform-shared.test.ts create mode 100644 packages/editor/src/components/tools/select/screen-rectangle-selection.ts create mode 100644 packages/editor/src/components/tools/select/select-candidates.test.ts create mode 100644 packages/editor/src/components/tools/shared/fresh-placement-visibility.ts create mode 100644 packages/editor/src/lib/fresh-planar-placement.test.ts create mode 100644 packages/editor/src/lib/fresh-planar-placement.ts create mode 100644 packages/editor/src/lib/placement-metadata.ts create mode 100644 packages/editor/src/lib/planar-cursor-placement.test.ts create mode 100644 packages/editor/src/lib/planar-cursor-placement.ts create mode 100644 packages/nodes/src/shared/floor-placement.test.ts create mode 100644 packages/nodes/src/shared/floor-placement.ts create mode 100644 packages/nodes/src/shared/floorplan-cursor.test.ts create mode 100644 packages/nodes/src/shared/floorplan-cursor.ts diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 6013f5fd..8682f457 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1286,8 +1286,8 @@ export type FloorPlacedConfig = { * serves both the static candidate and the moving node. * - `aabb` — an already-resolved XZ bounding box, for kinds whose plan * shape isn't a centred rectangle (stair: a segment chain or annular - * sector). Static candidates only — these kinds move by their origin, so - * the box's relocation path never needs them. + * sector). The moving-anchor bridge can relocate these by patching the + * proposed plan position and resolving the AABB again. * * `nodes` is supplied only when a kind needs siblings / children to resolve * its footprint (a straight stair walks its `stair-segment` children); box diff --git a/packages/core/src/services/alignment-anchors.test.ts b/packages/core/src/services/alignment-anchors.test.ts index ae796b3b..3b9361a7 100644 --- a/packages/core/src/services/alignment-anchors.test.ts +++ b/packages/core/src/services/alignment-anchors.test.ts @@ -13,6 +13,7 @@ import { collectAlignmentAnchors, footprintAABB, footprintAABBFrom, + movingAlignmentAnchors, movingFootprintAnchors, polygonAnchors, wallSegmentAnchors, @@ -196,6 +197,71 @@ describe('movingFootprintAnchors', () => { }) }) +describe('movingAlignmentAnchors', () => { + beforeEach(() => nodeRegistry._reset()) + + test('relocates a straight stair by its segment-chain footprint', () => { + registerNode(stairDef()) + const nodes = { + st: node({ + id: 'st', + type: 'stair', + position: [0, 0, 0], + rotation: 0, + stairType: 'straight', + width: 1, + children: ['seg'], + }), + seg: node({ + id: 'seg', + type: 'stair-segment', + parentId: 'st', + width: 1, + length: 3, + height: 2.5, + attachmentSide: 'front', + }), + } + + const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, 0) + expect(anchors).toHaveLength(4) + expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9.5, 10.5])) + expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([20, 23])) + }) + + test('rotation override drives a moving straight stair footprint', () => { + registerNode(stairDef()) + const nodes = { + st: node({ + id: 'st', + type: 'stair', + position: [0, 0, 0], + rotation: 0, + stairType: 'straight', + width: 1, + children: ['seg'], + }), + seg: node({ + id: 'seg', + type: 'stair-segment', + parentId: 'st', + width: 1, + length: 3, + height: 2.5, + attachmentSide: 'front', + }), + } + + const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, Math.PI / 2) + const xs = anchors.map((a) => a.x) + const zs = anchors.map((a) => a.z) + expect(Math.min(...xs)).toBeCloseTo(10, 10) + expect(Math.max(...xs)).toBeCloseTo(13, 10) + expect(Math.min(...zs)).toBeCloseTo(19.5, 10) + expect(Math.max(...zs)).toBeCloseTo(20.5, 10) + }) +}) + describe('wallSegmentAnchors', () => { test('returns both endpoints as corners and the chord midpoint as center', () => { const anchors = wallSegmentAnchors('w', [0, 0], [4, 2]) diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index 86d229f6..cf8bc974 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -152,6 +152,61 @@ export function movingFootprintAnchors( return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) } +function relocatedPlanNode(node: AnyNode, x: number, z: number, rotationY?: number): AnyNode { + const position = (node as { position?: unknown }).position + const y = Array.isArray(position) && typeof position[1] === 'number' ? position[1] : 0 + const relocated: Record = { + ...(node as Record), + position: [x, y, z], + } + + if (rotationY !== undefined && 'rotation' in node) { + const rotation = (node as { rotation?: unknown }).rotation + relocated.rotation = Array.isArray(rotation) + ? [rotation[0] ?? 0, rotationY, rotation[2] ?? 0] + : rotationY + } + + return relocated as AnyNode +} + +/** + * Corner anchors for a moving node relocated to the proposed plan position. + * Covers both the centred-box path (`floorPlaced.footprint` / + * `alignmentFootprint: box`) and explicit AABB footprints such as stairs, + * whose occupied plan bounds depend on children or curved/spiral geometry. + */ +export function movingAlignmentAnchors( + node: AnyNode, + nodes: Readonly> | undefined, + x: number, + z: number, + rotationY?: number, +): AlignmentAnchor[] { + const box = footprintAABBAt(node, x, z, rotationY) + if (box) return bboxCornerAnchors(node.id, box.minX, box.minZ, box.maxX, box.maxZ) + + const alignment = nodeRegistry + .get(node.type) + ?.capabilities?.alignmentFootprint?.(relocatedPlanNode(node, x, z, rotationY), nodes) + + if (alignment?.shape === 'box') { + const aabb = footprintAABBFrom([x, 0, z], alignment.dimensions, alignment.rotation[1] ?? 0) + return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) + } + if (alignment?.shape === 'aabb') { + return bboxCornerAnchors( + node.id, + alignment.minX, + alignment.minZ, + alignment.maxX, + alignment.maxZ, + ) + } + + return [] +} + /** * Alignment anchors for a wall segment: the two centerline endpoints + chord * midpoint, plus — when `thickness` is known — four **face** corner anchors, diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 56953c4a..c68e6770 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -15,6 +15,7 @@ export { footprintAABB, footprintAABBAt, footprintAABBFrom, + movingAlignmentAnchors, movingFootprintAnchors, nodeAlignmentAnchors, polygonAnchors, diff --git a/packages/core/src/store/use-scene-elevator-migration.test.ts b/packages/core/src/store/use-scene-elevator-migration.test.ts new file mode 100644 index 00000000..050180a0 --- /dev/null +++ b/packages/core/src/store/use-scene-elevator-migration.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +describe('scene elevator migrations', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('normalizes legacy level-parented elevators into building-scoped nodes', () => { + useScene.getState().setScene( + { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + }, + building_test: { + object: 'node', + id: 'building_test', + type: 'building', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'building_test', + visible: true, + metadata: {}, + children: ['elevator_test'], + level: 0, + }, + elevator_test: { + object: 'node', + id: 'elevator_test', + type: 'elevator', + parentId: 'level_test', + visible: true, + metadata: {}, + }, + } as unknown as Record, + ['site_test'] as never, + ) + + const nodes = useScene.getState().nodes + const elevator = nodes.elevator_test as Extract + const level = nodes.level_test as Extract + const building = nodes.building_test as Extract + + expect(elevator.parentId).toBe('building_test') + expect(elevator.position).toEqual([0, 0, 0]) + expect(elevator.rotation).toBe(0) + expect(level.children).not.toContain('elevator_test') + expect(building.children).toContain('elevator_test') + }) + + test('migrates level-parented elevators when the level parentId is missing', () => { + useScene.getState().setScene( + { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + }, + building_test: { + object: 'node', + id: 'building_test', + type: 'building', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: ['elevator_test'], + level: 0, + }, + elevator_test: { + object: 'node', + id: 'elevator_test', + type: 'elevator', + parentId: 'level_test', + visible: true, + metadata: {}, + }, + } as unknown as Record, + ['site_test'] as never, + ) + + const nodes = useScene.getState().nodes + const elevator = nodes.elevator_test as Extract + const level = nodes.level_test as Extract + const building = nodes.building_test as Extract + + expect(elevator.parentId).toBe('building_test') + expect(level.children).not.toContain('elevator_test') + expect(building.children).toContain('elevator_test') + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 90b6efff..7073857d 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -7,6 +7,7 @@ import { BuildingNode } from '../schema' import type { Collection, CollectionId } from '../schema/collections' import { generateCollectionId } from '../schema/collections' import { DoorNode as DoorNodeSchema } from '../schema/nodes/door' +import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator' import { LevelNode } from '../schema/nodes/level' import { getPitchFromActiveRoofHeight, @@ -149,6 +150,84 @@ function normalizeShelfNode(node: Record) { return parsed.success ? parsed.data : null } +function normalizeElevatorNode(node: Record) { + const sanitized = { + ...node, + position: getVector3(node.position, [0, 0, 0]), + rotation: getFiniteNumber(node.rotation, 0), + width: getFiniteNumber(node.width, 1.84), + depth: getFiniteNumber(node.depth, 1.84), + shaftWidth: node.shaftWidth === undefined ? undefined : getFiniteNumber(node.shaftWidth, 1.84), + shaftDepth: node.shaftDepth === undefined ? undefined : getFiniteNumber(node.shaftDepth, 1.84), + shaftWallThickness: getFiniteNumber(node.shaftWallThickness, 0.09), + cabHeight: getFiniteNumber(node.cabHeight, 2.35), + doorWidth: getFiniteNumber(node.doorWidth, 0.95), + doorHeight: getFiniteNumber(node.doorHeight, 2.1), + fromLevelId: getNullableString(node.fromLevelId), + toLevelId: getNullableString(node.toLevelId), + servedLevelIds: + node.servedLevelIds === undefined ? undefined : getStringArray(node.servedLevelIds), + disabledLevelIds: getStringArray(node.disabledLevelIds), + serviceOnlyLevelIds: getStringArray(node.serviceOnlyLevelIds), + defaultLevelId: getNullableString(node.defaultLevelId), + speed: getFiniteNumber(node.speed, 2.2), + doorDurationMs: getFiniteNumber(node.doorDurationMs, 900), + dwellMs: getFiniteNumber(node.dwellMs, 1400), + } + + const parsed = ElevatorNodeSchema.safeParse(sanitized) + return parsed.success ? parsed.data : null +} + +function findBuildingIdForLevel(levelId: string, nodes: Record): string | null { + const level = nodes[levelId] + const directBuildingId = typeof level?.parentId === 'string' ? level.parentId : null + if (directBuildingId && nodes[directBuildingId]?.type === 'building') { + return directBuildingId + } + + for (const [candidateId, candidate] of Object.entries(nodes)) { + if (candidate?.type !== 'building') continue + if (getStringArray(candidate.children).includes(levelId)) { + return candidateId + } + } + + return null +} + +function migrateElevatorParent( + id: string, + node: Record, + nodes: Record, +) { + const parentId = typeof node.parentId === 'string' ? node.parentId : null + if (!parentId) return node + const parent = parentId ? nodes[parentId] : null + if (parent?.type !== 'level') return node + + const buildingId = findBuildingIdForLevel(parentId, nodes) + if (!buildingId) return node + const building = buildingId ? nodes[buildingId] : null + if (building?.type !== 'building') return node + + nodes[parentId] = { + ...parent, + children: getStringArray(parent.children).filter((childId) => childId !== id), + } + + const buildingChildren = getStringArray(building.children) + nodes[buildingId] = { + ...building, + children: buildingChildren.includes(id) ? buildingChildren : [...buildingChildren, id], + } + + return { + ...node, + parentId: buildingId, + } +} + function migrateWallSurfaceMaterials(node: Record) { const hasInterior = node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string' @@ -440,6 +519,14 @@ function migrateNodes(nodes: Record): Record { } } + if (node.type === 'elevator') { + const parentMigrated = migrateElevatorParent(id, node, patchedNodes) + const normalized = normalizeElevatorNode(parentMigrated) + if (normalized) { + patchedNodes[id] = normalized + } + } + // Roof-segment hosting was added in this migration cycle (the same // pattern as shelf above). Older segments saved before the schema // gained `children` need the field initialised so 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 7d01dcb4..18a8883b 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 @@ -6,12 +6,12 @@ import { type AnyNodeId, bboxAnchors, bboxCornerAnchors, + emitter, type FloorplanMoveTargetSession, nodeRegistry, pauseSceneHistory, resolveAlignment, resumeSceneHistory, - snapPointToGrid, useAlignmentGuides, useLiveNodeOverrides, useLiveTransforms, @@ -19,12 +19,13 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' +import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement' +import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata' +import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts' -const GRID_STEP = 0.5 - // Figma-style alignment snap threshold. Meters in world space; 8cm gives // a comfortable "magnetic" pull at default zoom without fighting the // grid snap. Held fixed for v1 — a future revision can scale this with @@ -78,6 +79,21 @@ export function FloorplanRegistryMoveOverlay() { return [m.x, m.y] } + const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => { + // The scene's `` only covers painted SVG elements, so hovers over + // empty grid background often target the parent SVG. Bounds keep the + // cursor active anywhere inside the floor-plan viewport. + const svg = scene.ownerSVGElement + if (!svg) return false + const rect = svg.getBoundingClientRect() + return ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) + } + // ── Path 1 — kind-owned `floorplanMoveTarget` ─────────────────── if (hasMoveTarget && def?.floorplanMoveTarget) { const sceneNodes = useScene.getState().nodes @@ -109,26 +125,6 @@ export function FloorplanRegistryMoveOverlay() { // all entries use the action menu now. let hasMovedSinceStart = false - const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => { - // We can't just check `target.closest('[data-floorplan-scene]')` - // because the scene's `` only covers painted SVG elements — - // hovering empty grid background returns the parent SVG element - // as target (no ancestor with the marker), so the closest check - // fails. Compare the pointer position against the scene's - // bounding rect instead: any cursor inside the SVG viewport - // counts as "over the floor plan", regardless of whether the - // exact pixel paints a node or just blank surface. - const svg = scene.ownerSVGElement - if (!svg) return false - const rect = svg.getBoundingClientRect() - return ( - clientX >= rect.left && - clientX <= rect.right && - clientY >= rect.top && - clientY <= rect.bottom - ) - } - const onMove = (event: PointerEvent) => { // Skip 3D-canvas / other-UI cursor moves so the overlay only // tracks pointer events that actually correspond to a floor-plan @@ -175,6 +171,7 @@ export function FloorplanRegistryMoveOverlay() { } session.commit() sfxEmitter.emit('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) }) return } @@ -195,6 +192,24 @@ export function FloorplanRegistryMoveOverlay() { if (changed) finalUpdates.push({ id: snap.id, data }) } + for (const snap of snapshots) { + const current = sceneState[snap.id] + if (!current || !isFreshPlacementMetadata((current as { metadata?: unknown }).metadata)) { + continue + } + const existing = finalUpdates.find((update) => update.id === snap.id) + const metadata = stripPlacementMetadataFlags((current as { metadata?: unknown }).metadata) + if (existing) { + existing.data.metadata = metadata + existing.data.visible = true + } else { + finalUpdates.push({ + id: snap.id, + data: { metadata, visible: true }, + }) + } + } + if (commitValid && finalUpdates.length > 0) { // Single-undo dance: // 1. Revert to baseline while history is still paused. @@ -206,24 +221,6 @@ export function FloorplanRegistryMoveOverlay() { historyPaused = false } useScene.getState().updateNodes(finalUpdates) - // Strip the isNew metadata once committed (matches the legacy - // 3D move-tool that demotes duplicated nodes from "new" status - // on first successful drop). - for (const snap of snapshots) { - const current = useScene.getState().nodes[snap.id] - const meta = - current && typeof (current as { metadata?: unknown }).metadata === 'object' - ? ((current as { metadata?: Record }).metadata ?? {}) - : {} - if (meta.isNew) { - useScene.getState().updateNodes([ - { - id: snap.id, - data: { metadata: { ...meta, isNew: false } } as Record, - }, - ]) - } - } sfxEmitter.emit('sfx:item-place') // Re-select the moved node(s) — mirrors the legacy 3D move // tool. The action menu cleared selection on Move click so @@ -246,6 +243,7 @@ export function FloorplanRegistryMoveOverlay() { // reason as `onMove`: commits should land for any pointer-up // inside the SVG viewport, including empty grid background. if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return + if (!hasMovedSinceStart) return // Commit using the LAST pointermove's state — no re-apply at // pointer-up coords. A previous version re-applied here to @@ -281,17 +279,7 @@ export function FloorplanRegistryMoveOverlay() { // the following click are separate DOM events, so we listen on // window in the capture phase to intercept the click before any // bubble-phase handler (the floor-plan SVG) sees it. - const swallowClick = (e: MouseEvent) => { - e.stopPropagation() - e.preventDefault() - window.removeEventListener('click', swallowClick, true) - } - window.addEventListener('click', swallowClick, true) - // Safety net: if no click fires (e.g. user dragged enough to - // suppress it), drop the listener on the next tick. - setTimeout(() => { - window.removeEventListener('click', swallowClick, true) - }, 0) + swallowNextClick() } const onKey = (event: KeyboardEvent) => { @@ -300,6 +288,23 @@ export function FloorplanRegistryMoveOverlay() { // its own restore — without this, both sides would race to // write the same baseline, harmless but wasteful. setMovingNodeOrigin('2d') + if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) { + emitter.emit('tool:cancel') + useScene.getState().deleteNode(movingNode.id as AnyNodeId) + if (historyPaused) { + resumeSceneHistory(useScene) + historyPaused = false + } + const liveTransforms = useLiveTransforms.getState() + const liveOverrides = useLiveNodeOverrides.getState() + for (const id of session.affectedIds) { + liveTransforms.clear(id) + liveOverrides.clear(id) + } + useAlignmentGuides.getState().clear() + setMovingNode(null) + return + } // Revert untracked, then resume — no history entry. useScene.getState().updateNodes(snapshotsToUpdates(snapshots)) if (historyPaused) { @@ -389,6 +394,9 @@ export function FloorplanRegistryMoveOverlay() { position?: [number, number, number] } ).position ?? [0, 0, 0]) as [number, number, number] + const isFreshPlacement = isFreshPlacementMetadata( + (movingNode as { metadata?: unknown }).metadata, + ) // SVG units in this floorplan map 1:1 to world meters, and the // `` entry has no transform of its own when at rest, @@ -407,18 +415,29 @@ export function FloorplanRegistryMoveOverlay() { } let lastSnapped: [number, number] | null = null + let dragAnchor: [number, number] | null = null const onMove = (event: PointerEvent) => { // Same target guard as Path 1 — pointer must be over the floor // plan scene; otherwise we'd react to 3D-canvas moves with garbage // plan coords. - const target = event.target as Element | null - if (!target?.closest('[data-floorplan-scene]')) return + if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return const m = toMeters(event.clientX, event.clientY) if (!m) return - // 1) Grid snap baseline (unchanged behaviour with Alt held). - const [gridX, gridZ] = snapPointToGrid([m[0], m[1]], GRID_STEP) + // 1) Grid snap baseline. Fresh catalog placement is absolute under + // the cursor; existing moves preserve the cursor's grab offset. + const gridStep = useEditor.getState().gridSnapStep + const snap = (value: number) => Math.round(value / gridStep) * gridStep + const resolved = resolvePlanarCursorPosition({ + cursor: [m[0], m[1]], + original: [originalPosition[0], originalPosition[2]], + anchor: dragAnchor, + mode: isFreshPlacement ? 'absolute' : 'relative', + snap, + }) + dragAnchor = resolved.anchor + const [gridX, gridZ] = resolved.point // 2) Alignment snap layered on top. Treat the grid-snapped point // as the "proposed" position so alignment competes from a stable @@ -467,33 +486,52 @@ export function FloorplanRegistryMoveOverlay() { const onPointerUp = (event: PointerEvent) => { if (event.button !== 0) return - const target = event.target as Element | null - if (!target?.closest('[data-floorplan-scene]')) return + if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return const snapped = lastSnapped - if (snapped) { - const [sx, sz] = snapped - const [, oldY] = originalPosition - useScene - .getState() - .updateNode(movingNode.id as AnyNodeId, { position: [sx, oldY, sz] } as Partial) - const meta = (movingNode as unknown as { metadata?: Record }).metadata - if (meta?.isNew) { - useScene.getState().updateNode( + if (!snapped) return + const [sx, sz] = snapped + const [, oldY] = originalPosition + setMovingNodeOrigin('2d') + let selectedId = movingNode.id as AnyNodeId + if (isFreshPlacement) { + selectedId = + commitFreshPlacementSubtree( movingNode.id as AnyNodeId, { - metadata: { ...meta, isNew: false }, + position: [sx, oldY, sz], + metadata: stripPlacementMetadataFlags( + (movingNode as { metadata?: unknown }).metadata, + ), + visible: true, } as Partial, - ) - } + ) ?? selectedId + } else { + useScene.getState().updateNode( + movingNode.id as AnyNodeId, + { + position: [sx, oldY, sz], + } as Partial, + ) } + useViewer.getState().setSelection({ selectedIds: [selectedId] }) entry.removeAttribute('transform') useAlignmentGuides.getState().clear() setMovingNode(null) + swallowNextClick() } const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { + setMovingNodeOrigin('2d') + if (isFreshPlacement) { + emitter.emit('tool:cancel') + const temporal = useScene.temporal.getState() + const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false + if (wasTracking) temporal.pause() + useScene.getState().deleteNode(movingNode.id as AnyNodeId) + if (wasTracking) temporal.resume() + } entry.removeAttribute('transform') useAlignmentGuides.getState().clear() setMovingNode(null) @@ -557,3 +595,17 @@ function deepEqual(a: unknown, b: unknown): boolean { } return false } + +function swallowNextClick() { + const swallowClick = (e: MouseEvent) => { + e.stopPropagation() + e.preventDefault() + window.removeEventListener('click', swallowClick, true) + } + window.addEventListener('click', swallowClick, true) + // Safety net: if no click fires (e.g. user dragged enough to suppress it), + // drop the listener on the next tick. + setTimeout(() => { + window.removeEventListener('click', swallowClick, true) + }, 0) +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx index 07bdb723..316c78ee 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx @@ -23,13 +23,18 @@ import { memo, useEffect, useState } from 'react' */ export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({ geometry, + pointerEventsOverride, }: { geometry: FloorplanGeometry + pointerEventsOverride?: string }) { - return renderNode(geometry, 0) + return renderNode(geometry, 0, pointerEventsOverride) }) -function styleAttrs(g: FloorplanGeometry & { kind: Exclude }) { +function styleAttrs( + g: FloorplanGeometry & { kind: Exclude }, + pointerEventsOverride?: string, +) { // Shared SVG attribute mapping for any styled primitive. Keeps the per- // primitive switch arms terse and ensures new style fields land // everywhere at once. `as any` avoids re-asserting every variant @@ -60,21 +65,37 @@ function styleAttrs(g: FloorplanGeometry & { kind: Exclude + return case 'polygon': - return + return ( + + ) case 'polyline': - return + return ( + + ) case 'rect': return ( @@ -86,15 +107,32 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | width={g.width} x={g.x} y={g.y} - {...styleAttrs(g)} + {...styleAttrs(g, pointerEventsOverride)} /> ) case 'circle': - return + return ( + + ) case 'line': - return + return ( + + ) case 'text': return ( @@ -112,6 +150,7 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | strokeLinejoin={g.stroke ? 'round' : undefined} strokeWidth={g.strokeWidth} textAnchor={g.textAnchor ?? 'start'} + pointerEvents={pointerEventsOverride} x={g.x} y={g.y} > @@ -137,7 +176,7 @@ function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | const transform = formatTransform(g.transform) return ( - {g.children.map((child, i) => renderNode(child, i))} + {g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))} ) } 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 f27326f2..5646f8e2 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 @@ -187,11 +187,20 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const editorPhase = useEditor((s) => s.phase) const editorMode = useEditor((s) => s.mode) const editorTool = useEditor((s) => s.tool) + const structureLayer = useEditor((s) => s.structureLayer) + const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool) + const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) const isOpeningPlacementActive = (editorPhase === 'structure' && editorMode === 'build' && (editorTool === 'door' || editorTool === 'window')) || (movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement) + const isMarqueeSelectionActive = + editorMode === 'select' && + floorplanSelectionTool === 'marquee' && + structureLayer !== 'zones' && + !movingNode && + !movingFenceEndpoint // Subscribe to the live-transforms map ref so the layer re-renders // whenever a 3D mover publishes a per-frame position (see // `usePlacementCoordinator`). Without this the 2D floor plan only @@ -260,6 +269,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // tree the builder returns. Builders don't need to know about the // partition. const entries = useMemo(() => { + // Some builders read elevator runtime state imperatively; this keeps the memo subscribed. + void interactiveElevators + if (!levelId) return [] const out: { id: AnyNodeId @@ -273,6 +285,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const visit = (id: AnyNodeId) => { const node = nodes[id] if (!node) return + if ((node as { visible?: boolean }).visible === false) return const def = nodeRegistry.get(node.type) const builder = def?.floorplan if (builder) { @@ -373,6 +386,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const buildingScopedKindSet = new Set(buildingScopedKinds) for (const [id, node] of Object.entries(nodes)) { if (!node || !buildingScopedKindSet.has(node.type)) continue + if ((node as { visible?: boolean }).visible === false) continue const parentId = (node as { parentId?: AnyNodeId | null }).parentId if (parentId !== activeBuildingId) continue const cid = id as AnyNodeId @@ -383,8 +397,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const highlighted = highlightedIdSet.has(cid) const hovered = hoveredId === cid const moving = movingNode?.id === cid + const live = liveTransforms.get(cid) + const hasPosition = Array.isArray((node as { position?: unknown }).position) + let effectiveNode: AnyNode = + live && hasPosition ? applyPositionLiveTransform(node, live) : node + const contextNodes = def?.floorplanSiblingOverrides + ? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides }) + : nodes + if (contextNodes !== nodes) { + const merged = contextNodes[cid] + if (merged) { + effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged + } + } const ctx: GeometryContext = { - resolve: (rid: AnyNodeId): N | undefined => nodes[rid] as N | undefined, + resolve: (rid: AnyNodeId): N | undefined => + contextNodes[rid] as N | undefined, children: [], siblings: [], parent: activeLevelNode, @@ -399,12 +427,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { : undefined, } const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)( - node, + effectiveNode, ctx, ) if (geometry) { const { base, overlay } = splitFloorplanOverlay(geometry) - out.push({ id: cid, node, base, overlay, selected, highlighted }) + out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted }) } } } @@ -693,8 +721,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { className="floorplan-registry-entry" data-node-id={id} key={key} - onClick={isOpeningPlacementActive ? undefined : handleClickStop} - onPointerDown={isOpeningPlacementActive ? undefined : (e) => handleSelect(id, e)} + onClick={isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handleClickStop} + onPointerDown={ + isOpeningPlacementActive || isMarqueeSelectionActive + ? undefined + : (e) => handleSelect(id, e) + } // Mirror the sidebar tree nodes' hover wiring — `useViewer. // hoveredId` drives the highlight halo in 3D as well as the // wall / fence floor-plan hover stroke. Setting it on @@ -716,6 +748,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { geometry={geometry} hatchPatternId={renderCtx?.hatchPatternId} hoveredHandleId={hoveredHandleId} + isMarqueeSelectionActive={isMarqueeSelectionActive} nodeId={id} onHandleHoverChange={setHoveredHandleId} onHandlePointerDown={(affordance, payload, event, rotationPivot) => @@ -807,6 +840,7 @@ function InteractiveGeometry({ hatchPatternId, hoveredHandleId, activeDragId, + isMarqueeSelectionActive, nodeId, sceneRotationDeg, onHandleHoverChange, @@ -819,6 +853,7 @@ function InteractiveGeometry({ hatchPatternId: string | undefined hoveredHandleId: string | null activeDragId: string | null + isMarqueeSelectionActive: boolean nodeId: AnyNodeId sceneRotationDeg: number onHandleHoverChange: (id: string | null) => void @@ -860,7 +895,7 @@ function InteractiveGeometry({ return ( + return ( + + ) } } } diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 1fe75c4c..4bca280e 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -12,7 +12,14 @@ import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { Box3, Vector3 } from 'three' +import { + Box3, + type Camera, + type OrthographicCamera, + type PerspectiveCamera, + Spherical, + Vector3, +} from 'three' import { EDITOR_LAYER } from '../../lib/constants' import useEditor from '../../store/use-editor' @@ -23,8 +30,13 @@ const tempDelta = new Vector3() const tempPosition = new Vector3() const tempSize = new Vector3() const tempTarget = new Vector3() +const syncTarget = new Vector3() +const syncSpherical = new Spherical() const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 +const NAVIGATION_SYNC_POSITION_EPSILON = 0.001 +const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005 +const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001 type CameraMode = ReturnType['cameraMode'] type CameraPoseSnapshot = { mode: CameraMode @@ -64,6 +76,86 @@ function restoreCameraPose(control: CameraControlsImpl, pose: CameraPoseSnapshot ) } +function isEditableKeyboardTarget(target: EventTarget | null) { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable) + ) +} + +type CameraViewportSize = { + width: number + height: number +} + +function isPerspectiveCamera(camera: Camera): camera is PerspectiveCamera { + return (camera as PerspectiveCamera).isPerspectiveCamera === true +} + +function isOrthographicCamera(camera: Camera): camera is OrthographicCamera { + return (camera as OrthographicCamera).isOrthographicCamera === true +} + +function getCameraViewAspect(size: CameraViewportSize) { + return Math.max(size.width, 1) / Math.max(size.height, 1) +} + +function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewportSize) { + if (isPerspectiveCamera(camera)) { + const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180 + return Math.max(0.001, 2 * distance * Math.tan(fovRadians / 2) * getCameraViewAspect(size)) + } + + if (isOrthographicCamera(camera)) { + return Math.max(0.001, (camera.right - camera.left) / camera.zoom) + } + + return Math.max(0.001, distance) +} + +function getCameraDistanceForViewWidth( + camera: Camera, + viewWidth: number, + size: CameraViewportSize, +) { + if (!isPerspectiveCamera(camera)) { + return null + } + + const fovRadians = (camera.getEffectiveFOV() * Math.PI) / 180 + const denominator = 2 * Math.tan(fovRadians / 2) * getCameraViewAspect(size) + + return denominator > 0 ? Math.max(0.001, viewWidth / denominator) : null +} + +function getCameraZoomForViewWidth(camera: Camera, viewWidth: number) { + if (!isOrthographicCamera(camera)) { + return null + } + + return viewWidth > 0 ? Math.max(0.001, (camera.right - camera.left) / viewWidth) : null +} + +function applyCameraViewWidth( + control: CameraControlsImpl, + camera: Camera, + viewWidth: number, + size: CameraViewportSize, +) { + const nextDistance = getCameraDistanceForViewWidth(camera, viewWidth, size) + if (nextDistance !== null) { + control.dollyTo(nextDistance, true) + return + } + + const nextZoom = getCameraZoomForViewWidth(camera, viewWidth) + if (nextZoom !== null) { + control.zoomTo(nextZoom, true) + } +} + function useFirstPersonCameraPoseRestore( controls: { current: CameraControlsImpl | null }, isFirstPersonMode: boolean, @@ -131,6 +223,7 @@ export const CustomCameraControls = () => { const isPreviewMode = useEditor((s) => s.isPreviewMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) + const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen) const selection = useViewer((s) => s.selection) const cameraMode = useViewer((state) => state.cameraMode) const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore( @@ -140,11 +233,19 @@ export const CustomCameraControls = () => { ) const currentLevelId = selection.levelId const firstLoad = useRef(true) + const lastPublishedNavigationSync = useRef<{ + target: [number, number, number] + azimuth: number + viewWidth: number + } | null>(null) + const lastApplied2dNavigationRevision = useRef(0) const maxPolarAngle = !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE const camera = useThree((state) => state.camera) + const gl = useThree((state) => state.gl) const raycaster = useThree((state) => state.raycaster) + const viewportSize = useThree((state) => state.size) useEffect(() => { camera.layers.enable(EDITOR_LAYER) camera.layers.enable(GRID_LAYER) @@ -209,6 +310,73 @@ export const CustomCameraControls = () => { [isPreviewMode, isFirstPersonMode], ) + useEffect(() => { + if (isFirstPersonMode) return + + return useEditor.subscribe((state) => { + const pose = state.navigationSyncPose + if ( + !pose || + pose.source !== '2d' || + pose.revision === lastApplied2dNavigationRevision.current + ) + return + + const control = controls.current + if (!control) return + + lastApplied2dNavigationRevision.current = pose.revision + control.moveTo(pose.target[0], pose.target[1], pose.target[2], true) + control.rotateTo(pose.azimuth, control.polarAngle, true) + applyCameraViewWidth(control, camera, pose.viewWidth, viewportSize) + }) + }, [camera, isFirstPersonMode, viewportSize]) + + const publishCurrentNavigationPose = useCallback(() => { + if (isFirstPersonMode || !controls.current) return + + controls.current.getTarget(syncTarget, false) + controls.current.getSpherical(syncSpherical, false) + const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize) + + const previous = lastPublishedNavigationSync.current + if ( + previous && + Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON && + Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON && + Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON && + Math.abs(previous.azimuth - syncSpherical.theta) < NAVIGATION_SYNC_AZIMUTH_EPSILON && + Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON + ) { + return + } + + lastPublishedNavigationSync.current = { + target: [syncTarget.x, syncTarget.y, syncTarget.z], + azimuth: syncSpherical.theta, + viewWidth, + } + useEditor.getState().publishNavigationSyncPose({ + source: '3d', + target: [syncTarget.x, syncTarget.y, syncTarget.z], + azimuth: syncSpherical.theta, + viewWidth, + }) + }, [camera, isFirstPersonMode, viewportSize]) + + useEffect(() => { + if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return + + const frame = requestAnimationFrame(() => { + lastPublishedNavigationSync.current = null + publishCurrentNavigationPose() + }) + + return () => { + cancelAnimationFrame(frame) + } + }, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose]) + // Configure mouse buttons based on control mode and camera mode const mouseButtons = useMemo(() => { // Use ZOOM for orthographic camera, DOLLY for perspective camera @@ -284,6 +452,45 @@ export const CustomCameraControls = () => { controlLeft: false, space: false, } + let ownsNavigationCursor = false + let panPointerId: number | null = null + let panPointerButton: number | null = null + + const setNavigationCursor = (cursor: 'grab' | 'grabbing') => { + document.body.style.cursor = cursor + gl.domElement.style.cursor = cursor + ownsNavigationCursor = true + } + + const clearNavigationCursor = () => { + if ( + ownsNavigationCursor && + (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') + ) { + document.body.style.cursor = '' + } + if (ownsNavigationCursor && gl.domElement.style.cursor === 'grab') { + gl.domElement.style.cursor = '' + } + if (ownsNavigationCursor && gl.domElement.style.cursor === 'grabbing') { + gl.domElement.style.cursor = '' + } + ownsNavigationCursor = false + } + + const updateNavigationCursor = () => { + if (panPointerId !== null) { + setNavigationCursor('grabbing') + return + } + + if (keyState.space) { + setNavigationCursor('grab') + return + } + + clearNavigationCursor() + } const updateConfig = () => { if (!controls.current) return @@ -311,8 +518,10 @@ export const CustomCameraControls = () => { const onKeyDown = (event: KeyboardEvent) => { if (event.code === 'Space') { + if (isEditableKeyboardTarget(event.target)) return + event.preventDefault() keyState.space = true - document.body.style.cursor = 'grab' + updateNavigationCursor() } if (event.code === 'ShiftRight') { keyState.shiftRight = true @@ -332,7 +541,11 @@ export const CustomCameraControls = () => { const onKeyUp = (event: KeyboardEvent) => { if (event.code === 'Space') { keyState.space = false - document.body.style.cursor = '' + if (panPointerButton === 0) { + panPointerId = null + panPointerButton = null + } + updateNavigationCursor() } if (event.code === 'ShiftRight') { keyState.shiftRight = false @@ -349,16 +562,51 @@ export const CustomCameraControls = () => { updateConfig() } + const onPointerDown = (event: PointerEvent) => { + if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return + if (event.button !== 1 && !(event.button === 0 && keyState.space)) return + + panPointerId = event.pointerId + panPointerButton = event.button + updateNavigationCursor() + } + + const onPointerUp = (event: PointerEvent) => { + if (panPointerId === null) return + if (event.type !== 'pointercancel' && event.pointerId !== panPointerId) return + if (event.type !== 'pointercancel' && event.button !== panPointerButton) return + + panPointerId = null + panPointerButton = null + updateNavigationCursor() + } + + const onBlur = () => { + keyState.space = false + panPointerId = null + panPointerButton = null + clearNavigationCursor() + updateConfig() + } + document.addEventListener('keydown', onKeyDown) document.addEventListener('keyup', onKeyUp) + window.addEventListener('pointerdown', onPointerDown, true) + window.addEventListener('pointerup', onPointerUp, true) + window.addEventListener('pointercancel', onPointerUp, true) + window.addEventListener('blur', onBlur) updateConfig() return () => { document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keyup', onKeyUp) - document.body.style.cursor = '' + window.removeEventListener('pointerdown', onPointerDown, true) + window.removeEventListener('pointerup', onPointerUp, true) + window.removeEventListener('pointercancel', onPointerUp, true) + window.removeEventListener('blur', onBlur) + clearNavigationCursor() } - }, [cameraMode, isPreviewMode, isFirstPersonMode]) + }, [cameraMode, gl, isPreviewMode, isFirstPersonMode]) // Preview mode: auto-navigate camera to selected node (viewer behavior) const previewTargetNodeId = isPreviewMode @@ -669,6 +917,7 @@ export const CustomCameraControls = () => { minDistance={minDistance} minPolarAngle={0} mouseButtons={mouseButtons} + onUpdate={publishCurrentNavigationPose} onRest={onRest} onSleep={onRest} onTransitionStart={onTransitionStart} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index c7f2a466..390d3e48 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -76,7 +76,7 @@ import { import { guideEmitter } from '../../lib/guide-events' import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' -import type { GuideUiState } from '../../store/use-editor' +import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' import useEditor from '../../store/use-editor' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' @@ -97,6 +97,22 @@ import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-laye import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapToHalf } from '../tools/item/placement-math' +import { + isBoxSelectPointerSuppressed, + markBoxSelectHandled, +} from '../tools/select/box-select-state' +import { + createScreenRectangleSelectionElement, + hideScreenRectangleSelectionElement, + intersectScreenRects, + normalizeScreenRect, + SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX, + type ScreenRect, + screenRectFromDomRect, + screenRectsIntersect, + updateScreenRectangleSelectionElement, +} from '../tools/select/screen-rectangle-selection' +import { collectSelectableCandidateIds } from '../tools/select/select-candidates' import { formatAngleRadians, getAngleArcToSegmentReference, @@ -200,6 +216,7 @@ const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45 const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1 const FLOORPLAN_SITE_COLOR = '#10b981' const FLOORPLAN_VIEW_ROTATION_DEG = 90 +const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35 type FloorplanViewport = { centerX: number centerY: number @@ -221,6 +238,23 @@ type PanState = { pointerId: number clientX: number clientY: number + centerSvg: SvgPoint +} + +type FloorplanRotationState = { + pointerId: number + startClientX: number + initialUserRotationDeg: number + viewportCenterLocal: SvgPoint +} + +type FloorplanScreenSelectionState = { + pointerId: number + startClientX: number + startClientY: number + currentClientX: number + currentClientY: number + isDragging: boolean } type GestureLikeEvent = Event & { @@ -748,6 +782,67 @@ function getSelectionModifierKeys(event?: { metaKey?: boolean; ctrlKey?: boolean } } +function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement): string[] { + const scene = svg.querySelector('[data-floorplan-scene]') + if (!scene) { + return [] + } + + const candidateIds = collectSelectableCandidateIds() + if (candidateIds.length === 0) { + return [] + } + + const candidateIdSet = new Set(candidateIds) + const hitIds = new Set() + const baseElementsById = new Map() + const fallbackElementsById = new Map() + const baseLayer = scene.querySelector('.floorplan-registry-base') + + for (const element of scene.querySelectorAll('[data-node-id]')) { + const id = element.getAttribute('data-node-id') + if (!id || !candidateIdSet.has(id)) { + continue + } + + const targetMap = baseLayer?.contains(element) ? baseElementsById : fallbackElementsById + const existing = targetMap.get(id) + if (existing) { + existing.push(element) + } else { + targetMap.set(id, [element]) + } + } + + for (const id of candidateIds) { + const elements = baseElementsById.get(id) ?? fallbackElementsById.get(id) ?? [] + for (const element of elements) { + const elementRect = element.getBoundingClientRect() + if (elementRect.width <= 0 && elementRect.height <= 0) { + continue + } + + if (screenRectsIntersect(rect, screenRectFromDomRect(elementRect))) { + hitIds.add(id) + break + } + } + } + + return candidateIds.filter((id) => hitIds.has(id)) +} + +function swallowNextFloorplanScreenSelectionClick() { + const swallowClick = (event: Event) => { + event.preventDefault() + event.stopPropagation() + window.removeEventListener('click', swallowClick, true) + } + + window.addEventListener('click', swallowClick, true) + window.setTimeout(() => window.removeEventListener('click', swallowClick, true), 200) +} + function toPoint2D(point: WallPlanPoint): Point2D { return { x: point[0], y: point[1] } } @@ -1565,6 +1660,72 @@ function rotateSvgPoint(point: SvgPoint, rotationDegrees: number): SvgPoint { } } +function radiansToDegrees(angle: number) { + return (angle * 180) / Math.PI +} + +function degreesToRadians(angle: number) { + return (angle * Math.PI) / 180 +} + +function nearestEquivalentDegrees(angle: number, reference: number) { + let nextAngle = angle + + while (nextAngle - reference > 180) { + nextAngle -= 360 + } + + while (nextAngle - reference < -180) { + nextAngle += 360 + } + + return nextAngle +} + +function floorplanRotationFromCameraAzimuth(azimuth: number, reference: number) { + return nearestEquivalentDegrees( + radiansToDegrees(azimuth) - FLOORPLAN_VIEW_ROTATION_DEG, + reference, + ) +} + +function cameraAzimuthFromFloorplanRotation(rotationDeg: number) { + return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG) +} + +function floorplanLocalToWorldPoint( + point: SvgPoint | WallPlanPoint, + buildingPosition: readonly [number, number, number], + buildingRotationY: number, +): { x: number; z: number } { + const localX = Array.isArray(point) ? point[0] : point.x + const localY = Array.isArray(point) ? point[1] : point.y + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + + return { + x: buildingPosition[0] + localX * cos + localY * sin, + z: buildingPosition[2] - localX * sin + localY * cos, + } +} + +function worldToFloorplanLocalPoint( + worldX: number, + worldZ: number, + buildingPosition: readonly [number, number, number], + buildingRotationY: number, +): SvgPoint { + const dx = worldX - buildingPosition[0] + const dz = worldZ - buildingPosition[2] + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + + return { + x: dx * cos - dz * sin, + y: dx * sin + dz * cos, + } +} + function projectSvgPointToSurface( svgPoint: SvgPoint, viewBox: { minX: number; minY: number; width: number; height: number }, @@ -4089,6 +4250,9 @@ export function FloorplanPanel() { const floorplanSceneRef = useRef(null) const floorplanContentRef = useRef(null) const panStateRef = useRef(null) + const floorplanRotationStateRef = useRef(null) + const floorplanSpacePanPressedRef = useRef(false) + const floorplanNavigationClickSuppressedRef = useRef(false) const guideInteractionRef = useRef(null) const guideTransformDraftRef = useRef(null) const pendingFenceDragRef = useRef(null) @@ -4102,6 +4266,15 @@ export function FloorplanPanel() { const hasUserAdjustedViewportRef = useRef(false) const previousLevelIdRef = useRef(null) const floorplanMarqueeSnapPointRef = useRef(null) + const floorplanScreenSelectionRef = useRef(null) + const floorplanScreenSelectionElementRef = useRef(null) + const floorplanScreenSelectionOwnsInputDraggingRef = useRef(false) + const latestFloorplanUserRotationDegRef = useRef(0) + const latestViewportRef = useRef(null) + const latestFittedViewportRef = useRef(null) + const latestNavigationSyncPoseRef = useRef( + useEditor.getState().navigationSyncPose, + ) const levelId = useViewer((state) => state.selection.levelId) const buildingId = useViewer((state) => state.selection.buildingId) const selectedZoneId = useViewer((state) => state.selection.zoneId) @@ -4198,8 +4371,11 @@ export function FloorplanPanel() { }) }), ) + const [floorplanUserRotationDeg, setFloorplanUserRotationDeg] = useState(0) const buildingRotationDeg = (buildingRotationY * 180) / Math.PI - const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - buildingRotationDeg + const floorplanSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg + latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg const [draftStart, setDraftStart] = useState(null) const [draftEnd, setDraftEnd] = useState(null) @@ -4308,7 +4484,9 @@ export function FloorplanPanel() { ) const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null) const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) + const [isSpacePanPressed, setIsSpacePanPressed] = useState(false) const [isPanning, setIsPanning] = useState(false) + const [isRotatingFloorplan, setIsRotatingFloorplan] = useState(false) const [isDraggingPanel, setIsDraggingPanel] = useState(false) const [isMacPlatform, setIsMacPlatform] = useState(true) const [activeResizeDirection, setActiveResizeDirection] = useState(null) @@ -4322,6 +4500,7 @@ export function FloorplanPanel() { const [isPanelReady, setIsPanelReady] = useState(false) const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 }) const [viewport, setViewport] = useState(null) + latestViewportRef.current = viewport // Tight bbox of the painted floor-plan scene (the rotation ``'s // children), read via SVG `getBBox()` after each render. The legacy // polygon arrays (`wallPolygons`, `displaySlabPolygons`, etc.) are now @@ -5019,6 +5198,14 @@ export function FloorplanPanel() { !movingNode && !movingFenceEndpoint && structureLayer !== 'zones' + const isScreenSelectionToolActive = + mode === 'select' && + floorplanSelectionTool === 'click' && + (phase === 'structure' || phase === 'furnish') && + !movingNode && + !movingFenceEndpoint && + !referenceScaleDraft && + !pendingReferenceScale const isDeleteMode = mode === 'delete' && !movingNode const canSelectElementFloorplanGeometry = mode === 'select' && @@ -5400,6 +5587,7 @@ export function FloorplanPanel() { visibleZonePolygons, wallPolygons, ]) + latestFittedViewportRef.current = fittedViewport // Measure the painted floor-plan scene after each render. `getBBox()` // gives us the tight bounds of whatever the registry layer emitted, @@ -5438,6 +5626,78 @@ export function FloorplanPanel() { }) }) + const syncFloorplanViewportToNavigationPose = useCallback( + (pose: NavigationSyncPose) => { + const nextUserRotationDeg = floorplanRotationFromCameraAzimuth( + pose.azimuth, + latestFloorplanUserRotationDegRef.current, + ) + const localCenter = worldToFloorplanLocalPoint( + pose.target[0], + pose.target[2], + buildingPosition, + buildingRotationY, + ) + const nextSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + nextUserRotationDeg - buildingRotationDeg + const centerSvg = rotateSvgPoint(localCenter, nextSceneRotationDeg) + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + const fitted = latestFittedViewportRef.current + + if (!currentViewport) { + return + } + + const minWidth = fitted ? fitted.width * MIN_VIEWPORT_WIDTH_RATIO : 0.001 + const maxWidth = fitted ? fitted.width * MAX_VIEWPORT_WIDTH_RATIO : Number.POSITIVE_INFINITY + const nextWidth = clamp(pose.viewWidth, minWidth, maxWidth) + + const nextViewport = { + centerX: centerSvg.x, + centerY: centerSvg.y, + width: nextWidth, + } + + hasUserAdjustedViewportRef.current = true + latestFloorplanUserRotationDegRef.current = nextUserRotationDeg + latestViewportRef.current = nextViewport + setFloorplanUserRotationDeg((current) => + current === nextUserRotationDeg ? current : nextUserRotationDeg, + ) + setViewport((current) => + floorplanViewportEquals(current, nextViewport) ? current : nextViewport, + ) + }, + [buildingPosition, buildingRotationDeg, buildingRotationY], + ) + + useEffect(() => { + const pose = useEditor.getState().navigationSyncPose + if (!pose) { + return + } + + latestNavigationSyncPoseRef.current = pose + if (pose.source === '3d') { + syncFloorplanViewportToNavigationPose(pose) + } + }, [syncFloorplanViewportToNavigationPose]) + + useEffect(() => { + return useEditor.subscribe((state) => { + const pose = state.navigationSyncPose + if (!pose || latestNavigationSyncPoseRef.current?.revision === pose.revision) { + return + } + + latestNavigationSyncPoseRef.current = pose + + if (pose.source === '3d') { + syncFloorplanViewportToNavigationPose(pose) + } + }) + }, [syncFloorplanViewportToNavigationPose]) + useEffect(() => { const host = viewportHostRef.current if (!host) { @@ -5492,10 +5752,24 @@ export function FloorplanPanel() { // editor would show the same off-screen viewport instead of fitting // to the current scene. useEffect(() => { - if (!isFloorplanOpen) return - hasUserAdjustedViewportRef.current = false - setViewport(null) + if (!isFloorplanOpen) { + floorplanSpacePanPressedRef.current = false + panStateRef.current = null + floorplanRotationStateRef.current = null + setIsSpacePanPressed(false) + setIsPanning(false) + setIsRotatingFloorplan(false) + return + } setMeasuredSceneBBox(null) + + if (!latestNavigationSyncPoseRef.current) { + hasUserAdjustedViewportRef.current = false + latestFloorplanUserRotationDegRef.current = 0 + latestViewportRef.current = null + setFloorplanUserRotationDeg(0) + setViewport(null) + } }, [isFloorplanOpen]) useEffect(() => { @@ -5503,10 +5777,13 @@ export function FloorplanPanel() { if (levelChanged) { previousLevelIdRef.current = levelId ?? null - hasUserAdjustedViewportRef.current = false - setViewport((current) => - floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport, - ) + if (!latestNavigationSyncPoseRef.current) { + hasUserAdjustedViewportRef.current = false + latestFloorplanUserRotationDegRef.current = 0 + latestViewportRef.current = null + setFloorplanUserRotationDeg(0) + setViewport(null) + } return } @@ -6158,6 +6435,43 @@ export function FloorplanPanel() { setViewport(nextViewport) }, []) + const floorplanGridLocalY = useMemo(() => { + if (movingNode?.type === 'item' || movingNode?.type === 'spawn') { + return movingNode.position[1] + } + + if (levelId) { + return sceneRegistry.nodes.get(levelId as AnyNodeId)?.position.y ?? 0 + } + + return 0 + }, [levelId, movingNode]) + const floorplanGridWorldY = buildingPosition[1] + floorplanGridLocalY + const publishFloorplanNavigationPose = useCallback( + (localCenter: SvgPoint, userRotationDeg: number, viewWidth?: number) => { + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + const resolvedViewWidth = viewWidth ?? currentViewport?.width + if (!(resolvedViewWidth && resolvedViewWidth > 0)) { + return + } + + const worldCenter = floorplanLocalToWorldPoint( + localCenter, + buildingPosition, + buildingRotationY, + ) + const targetY = latestNavigationSyncPoseRef.current?.target[1] ?? floorplanGridWorldY + + useEditor.getState().publishNavigationSyncPose({ + source: '2d', + target: [worldCenter.x, targetY, worldCenter.z], + azimuth: cameraAzimuthFromFloorplanRotation(userRotationDeg), + viewWidth: resolvedViewWidth, + }) + }, + [buildingPosition, buildingRotationY, floorplanGridWorldY], + ) + const clearGuideInteraction = useCallback(() => { guideInteractionRef.current = null guideTransformDraftRef.current = null @@ -6319,9 +6633,10 @@ export function FloorplanPanel() { const currentViewport = viewport ?? fittedViewport const currentViewBox = viewBox - const nextWidth = Math.min( + const nextWidth = clamp( + currentViewport.width * widthFactor, + minViewportWidth, maxViewportWidth, - Math.max(minViewportWidth, currentViewport.width * widthFactor), ) const nextHeight = nextWidth / svgAspectRatio const normalizedX = (svgPoint.x - currentViewBox.minX) / currentViewBox.width @@ -6329,11 +6644,17 @@ export function FloorplanPanel() { const nextMinX = svgPoint.x - normalizedX * nextWidth const nextMinY = svgPoint.y - normalizedY * nextHeight - updateViewport({ - centerX: nextMinX + nextWidth / 2, - centerY: nextMinY + nextHeight / 2, - width: nextWidth, - }) + const nextCenterSvg: SvgPoint = { + x: nextMinX + nextWidth / 2, + y: nextMinY + nextHeight / 2, + } + const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg) + + publishFloorplanNavigationPose( + localCenter, + latestFloorplanUserRotationDegRef.current, + nextWidth, + ) }, [ fittedViewport, @@ -6341,8 +6662,8 @@ export function FloorplanPanel() { getSvgPointFromClientPoint, maxViewportWidth, minViewportWidth, + publishFloorplanNavigationPose, svgAspectRatio, - updateViewport, viewBox, viewport, ], @@ -6657,12 +6978,19 @@ export function FloorplanPanel() { const isEditableTarget = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || Boolean(target?.isContentEditable) if (isEditableTarget) { return } + if (event.code === 'Space' && isFloorplanOpen) { + event.preventDefault() + floorplanSpacePanPressedRef.current = true + setIsSpacePanPressed(true) + } + if (event.key === 'Shift') { setShiftPressed(true) } @@ -6687,6 +7015,11 @@ export function FloorplanPanel() { ) } const handleKeyUp = (event: KeyboardEvent) => { + if (event.code === 'Space') { + floorplanSpacePanPressedRef.current = false + setIsSpacePanPressed(false) + } + if (event.key === 'Shift') { setShiftPressed(false) } @@ -6694,6 +7027,8 @@ export function FloorplanPanel() { setRotationModifierPressed(event.metaKey || event.ctrlKey) } const handleBlur = () => { + floorplanSpacePanPressedRef.current = false + setIsSpacePanPressed(false) setShiftPressed(false) setRotationModifierPressed(false) } @@ -6707,7 +7042,7 @@ export function FloorplanPanel() { window.removeEventListener('keyup', handleKeyUp) window.removeEventListener('blur', handleBlur) } - }, [isStairBuildActive, movingNode]) + }, [isFloorplanOpen, isStairBuildActive, movingNode]) useEffect(() => { const handleWindowPointerMove = (event: PointerEvent) => { @@ -7185,47 +7520,116 @@ export function FloorplanPanel() { } }, [setFloorplanHovered]) - const handlePointerDown = useCallback((event: ReactPointerEvent) => { - if (event.button !== 2) { - return - } + const handleNavigationPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button === 1 || (event.button === 0 && floorplanSpacePanPressedRef.current)) { + event.preventDefault() + event.stopPropagation() - event.preventDefault() - event.stopPropagation() + floorplanNavigationClickSuppressedRef.current = true + const currentViewport = viewport ?? fittedViewport + panStateRef.current = { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + centerSvg: { + x: currentViewport.centerX, + y: currentViewport.centerY, + }, + } + setIsPanning(true) + setCursorPoint(null) + setFloorplanCursorPosition(null) - panStateRef.current = { - pointerId: event.pointerId, - clientX: event.clientX, - clientY: event.clientY, - } - setIsPanning(true) + event.currentTarget.setPointerCapture(event.pointerId) + return + } - event.currentTarget.setPointerCapture(event.pointerId) - }, []) + if (event.button !== 2) { + return + } - const endPanning = useCallback((event?: ReactPointerEvent) => { - if (event && panStateRef.current && event.currentTarget.hasPointerCapture(event.pointerId)) { + event.preventDefault() + event.stopPropagation() + + const currentViewport = viewport ?? fittedViewport + const viewportCenterLocal = rotateSvgPoint( + { x: currentViewport.centerX, y: currentViewport.centerY }, + -floorplanSceneRotationDeg, + ) + + floorplanRotationStateRef.current = { + pointerId: event.pointerId, + startClientX: event.clientX, + initialUserRotationDeg: floorplanUserRotationDeg, + viewportCenterLocal, + } + setIsRotatingFloorplan(true) + setCursorPoint(null) + setFloorplanCursorPosition(null) + + event.currentTarget.setPointerCapture(event.pointerId) + }, + [fittedViewport, floorplanSceneRotationDeg, floorplanUserRotationDeg, viewport], + ) + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button === 0) { + if (!isScreenSelectionToolActive || event.defaultPrevented) { + return + } + + const target = event.target instanceof Element ? event.target : null + if (target?.closest('[data-node-id]')) { + return + } + + const viewer = useViewer.getState() + if ( + viewer.cameraDragging || + viewer.inputDragging || + isBoxSelectPointerSuppressed(event.nativeEvent) + ) { + return + } + + floorplanScreenSelectionRef.current = { + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + currentClientX: event.clientX, + currentClientY: event.clientY, + isDragging: false, + } + setPreviewSelectedIds([]) + return + } + }, + [isScreenSelectionToolActive, setPreviewSelectedIds], + ) + + const endFloorplanNavigation = useCallback((event?: ReactPointerEvent) => { + if ( + event && + (panStateRef.current || floorplanRotationStateRef.current) && + event.currentTarget.hasPointerCapture(event.pointerId) + ) { event.currentTarget.releasePointerCapture(event.pointerId) } panStateRef.current = null + floorplanRotationStateRef.current = null setIsPanning(false) + setIsRotatingFloorplan(false) + + window.setTimeout(() => { + floorplanNavigationClickSuppressedRef.current = false + }, 0) }, []) const hoveredWallIdRef = useRef(null) const hoveredCeilingIdRef = useRef(null) - const floorplanGridLocalY = useMemo(() => { - if (movingNode?.type === 'item' || movingNode?.type === 'spawn') { - return movingNode.position[1] - } - - if (levelId) { - return sceneRegistry.nodes.get(levelId as AnyNodeId)?.position.y ?? 0 - } - - return 0 - }, [levelId, movingNode]) - const floorplanGridWorldY = buildingPosition[1] + floorplanGridLocalY const emitFloorplanWallLeave = useCallback((wallId: string | null) => { if (!wallId) { return @@ -7412,22 +7816,45 @@ export function FloorplanPanel() { const handlePointerMove = useCallback( (event: ReactPointerEvent) => { + const rotationState = floorplanRotationStateRef.current + if (rotationState?.pointerId === event.pointerId) { + event.preventDefault() + event.stopPropagation() + + const angleDeltaDeg = + (event.clientX - rotationState.startClientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL + const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg + + publishFloorplanNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg) + setCursorPoint(null) + return + } + if (panStateRef.current?.pointerId === event.pointerId) { + event.preventDefault() + event.stopPropagation() + const deltaX = event.clientX - panStateRef.current.clientX const deltaY = event.clientY - panStateRef.current.clientY const worldPerPixelX = viewBox.width / surfaceSize.width const worldPerPixelY = viewBox.height / surfaceSize.height - updateViewport({ - centerX: (viewport ?? fittedViewport).centerX - deltaX * worldPerPixelX, - centerY: (viewport ?? fittedViewport).centerY - deltaY * worldPerPixelY, - width: (viewport ?? fittedViewport).width, - }) + const nextCenterSvg = { + x: panStateRef.current.centerSvg.x - deltaX * worldPerPixelX, + y: panStateRef.current.centerSvg.y - deltaY * worldPerPixelY, + } + const currentUserRotationDeg = latestFloorplanUserRotationDegRef.current + const currentSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg + const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg) + + publishFloorplanNavigationPose(localCenter, currentUserRotationDeg) panStateRef.current = { pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, + centerSvg: nextCenterSvg, } setCursorPoint(null) return @@ -7699,6 +8126,7 @@ export function FloorplanPanel() { }) }, [ + buildingRotationDeg, draftStart, ceilingDraftPoints, emitFloorplanWallLeave, @@ -7706,7 +8134,6 @@ export function FloorplanPanel() { fences, fenceDraftStart, floorplanOpeningLocalY, - fittedViewport, getPlanPointFromClientPoint, activePolygonDraftPoints, handleCeilingItemPlacementMove, @@ -7719,6 +8146,7 @@ export function FloorplanPanel() { isPolygonBuildActive, isRoofBuildActive, isWallBuildActive, + publishFloorplanNavigationPose, referenceScaleDraft, roofDraftStart, elevatorResizeDragState, @@ -7726,10 +8154,8 @@ export function FloorplanPanel() { shiftPressed, surfaceSize.height, surfaceSize.width, - updateViewport, viewBox.height, viewBox.width, - viewport, walls, ], ) @@ -8101,6 +8527,19 @@ export function FloorplanPanel() { emitFloorplanGridEvent, ], ) + const handleSvgClick = useCallback( + (event: ReactMouseEvent) => { + if (floorplanNavigationClickSuppressedRef.current) { + event.preventDefault() + event.stopPropagation() + floorplanNavigationClickSuppressedRef.current = false + return + } + + handleBackgroundClick(event) + }, + [handleBackgroundClick], + ) const handleBackgroundDoubleClick = useCallback( (event: ReactMouseEvent) => { if (!(isPolygonDraftBuildActive && !isRoofBuildActive)) { @@ -8227,6 +8666,197 @@ export function FloorplanPanel() { [setPreviewSelectedIds], ) + const resetFloorplanScreenSelection = useCallback(() => { + floorplanScreenSelectionRef.current = null + hideScreenRectangleSelectionElement(floorplanScreenSelectionElementRef.current) + syncPreviewSelectedIds([]) + + if (floorplanScreenSelectionOwnsInputDraggingRef.current) { + useViewer.getState().setInputDragging(false) + floorplanScreenSelectionOwnsInputDraggingRef.current = false + } + }, [syncPreviewSelectedIds]) + + const commitFloorplanScreenSelection = useCallback( + (nextSelectedIds: string[], event: PointerEvent) => { + const modifierKeys = getSelectionModifierKeys(event) + const shouldAppend = modifierKeys.meta || modifierKeys.ctrl + + setSelectedReferenceId(null) + + if (phase === 'structure' && structureLayer === 'zones') { + if (nextSelectedIds.length > 0) { + setSelection({ zoneId: nextSelectedIds[0] as ZoneNodeType['id'] }) + } else if (!shouldAppend) { + setSelection({ zoneId: null }) + } + return + } + + addFloorplanSelection(nextSelectedIds, modifierKeys) + }, + [addFloorplanSelection, phase, setSelectedReferenceId, setSelection, structureLayer], + ) + + useEffect(() => { + const element = createScreenRectangleSelectionElement() + document.body.appendChild(element) + floorplanScreenSelectionElementRef.current = element + + return () => { + element.remove() + floorplanScreenSelectionElementRef.current = null + } + }, []) + + useEffect(() => { + if (!isScreenSelectionToolActive) { + resetFloorplanScreenSelection() + } + }, [isScreenSelectionToolActive, resetFloorplanScreenSelection]) + + useEffect(() => { + const updateDrag = (event: PointerEvent) => { + const state = floorplanScreenSelectionRef.current + if (!state || event.pointerId !== state.pointerId) { + return + } + + const viewer = useViewer.getState() + if ( + !isScreenSelectionToolActive || + isBoxSelectPointerSuppressed(event) || + viewer.cameraDragging || + (viewer.inputDragging && !floorplanScreenSelectionOwnsInputDraggingRef.current) + ) { + markBoxSelectHandled() + resetFloorplanScreenSelection() + return + } + + state.currentClientX = event.clientX + state.currentClientY = event.clientY + + const dragDistance = Math.hypot( + state.currentClientX - state.startClientX, + state.currentClientY - state.startClientY, + ) + + if (!state.isDragging && dragDistance >= SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX) { + state.isDragging = true + floorplanScreenSelectionOwnsInputDraggingRef.current = true + useViewer.getState().setInputDragging(true) + markBoxSelectHandled() + + try { + svgRef.current?.setPointerCapture(event.pointerId) + } catch {} + } + + if (!state.isDragging) { + return + } + + event.preventDefault() + + const svg = svgRef.current + const element = floorplanScreenSelectionElementRef.current + if (!(svg && element)) { + resetFloorplanScreenSelection() + return + } + + const rect = normalizeScreenRect( + state.startClientX, + state.startClientY, + state.currentClientX, + state.currentClientY, + ) + const clampedRect = intersectScreenRects( + rect, + screenRectFromDomRect(svg.getBoundingClientRect()), + ) + + if (!clampedRect) { + hideScreenRectangleSelectionElement(element) + syncPreviewSelectedIds([]) + return + } + + updateScreenRectangleSelectionElement(element, clampedRect) + syncPreviewSelectedIds(collectFloorplanScreenSelectionIds(clampedRect, svg)) + } + + const finishDrag = (event: PointerEvent) => { + const state = floorplanScreenSelectionRef.current + if (!state || event.pointerId !== state.pointerId) { + return + } + + if ( + isBoxSelectPointerSuppressed(event) || + (useViewer.getState().inputDragging && + !floorplanScreenSelectionOwnsInputDraggingRef.current) + ) { + markBoxSelectHandled() + resetFloorplanScreenSelection() + return + } + + if (state.isDragging) { + event.preventDefault() + event.stopPropagation() + markBoxSelectHandled() + + const svg = svgRef.current + const rect = normalizeScreenRect( + state.startClientX, + state.startClientY, + event.clientX, + event.clientY, + ) + const clampedRect = svg + ? intersectScreenRects(rect, screenRectFromDomRect(svg.getBoundingClientRect())) + : null + const ids = svg && clampedRect ? collectFloorplanScreenSelectionIds(clampedRect, svg) : [] + + commitFloorplanScreenSelection(ids, event) + swallowNextFloorplanScreenSelectionClick() + } + + try { + svgRef.current?.releasePointerCapture(event.pointerId) + } catch {} + + resetFloorplanScreenSelection() + } + + const cancelDrag = (event: PointerEvent) => { + const state = floorplanScreenSelectionRef.current + if (!state || event.pointerId !== state.pointerId) { + return + } + + resetFloorplanScreenSelection() + } + + window.addEventListener('pointermove', updateDrag, { passive: false }) + window.addEventListener('pointerup', finishDrag) + window.addEventListener('pointercancel', cancelDrag) + + return () => { + window.removeEventListener('pointermove', updateDrag) + window.removeEventListener('pointerup', finishDrag) + window.removeEventListener('pointercancel', cancelDrag) + resetFloorplanScreenSelection() + } + }, [ + commitFloorplanScreenSelection, + isScreenSelectionToolActive, + resetFloorplanScreenSelection, + syncPreviewSelectedIds, + ]) + const handleGuideSelect = useCallback( (guideId: GuideNode['id']) => { setSelectedReferenceId(guideId) @@ -8457,7 +9087,14 @@ export function FloorplanPanel() { ) const handlePointerLeave = useCallback(() => { - if (!(panStateRef.current || wallEndpointDragRef.current || siteVertexDragState)) { + if ( + !( + panStateRef.current || + floorplanRotationStateRef.current || + wallEndpointDragRef.current || + siteVertexDragState + ) + ) { setCursorPoint(null) } setHoveredSiteHandleId(null) @@ -8481,7 +9118,9 @@ export function FloorplanPanel() { (event: ReactPointerEvent) => { if ( hasFloorplanCursorIndicator && + !isSpacePanPressed && !panStateRef.current && + !floorplanRotationStateRef.current && !guideInteractionRef.current && !elevatorResizeDragState && !wallEndpointDragRef.current && @@ -8507,7 +9146,13 @@ export function FloorplanPanel() { handlePointerMove(event) }, - [handlePointerMove, hasFloorplanCursorIndicator, elevatorResizeDragState, siteVertexDragState], + [ + handlePointerMove, + hasFloorplanCursorIndicator, + isSpacePanPressed, + elevatorResizeDragState, + siteVertexDragState, + ], ) const handleSvgPointerLeave = useCallback(() => { @@ -8859,6 +9504,9 @@ export function FloorplanPanel() { : activeDraftAnchorPoint ? palette.draftStroke : palette.cursor + const floorplanNavigationCursor = + isPanning || isRotatingFloorplan ? 'grabbing' : isSpacePanPressed ? 'grab' : null + const isFloorplanNavigationOverlayVisible = isSpacePanPressed || isPanning || isRotatingFloorplan const pendingReferenceDisplayLength = Number(referenceScaleValue) const pendingReferenceRealLengthMeters = pendingReferenceScale && pendingReferenceDisplayLength > 0 @@ -8897,7 +9545,7 @@ export function FloorplanPanel() { indicatorBadgeOffsetX={FLOORPLAN_CURSOR_BADGE_OFFSET_X} indicatorBadgeOffsetY={FLOORPLAN_CURSOR_BADGE_OFFSET_Y} indicatorLineHeight={FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT} - isPanning={isPanning} + isPanning={isPanning || isRotatingFloorplan} movingOpeningType={movingOpeningType} /> {showGuides && canInteractWithGuides && selectedGuide && ( @@ -9030,16 +9678,20 @@ export function FloorplanPanel() { ) : ( event.preventDefault()} onDoubleClick={isMarqueeSelectionToolActive ? undefined : handleBackgroundDoubleClick} - onPointerCancel={endPanning} + onPointerCancel={endFloorplanNavigation} onPointerDown={handlePointerDown} + onPointerDownCapture={handleNavigationPointerDown} onPointerLeave={handleSvgPointerLeave} onPointerMove={handleSvgPointerMove} - onPointerUp={endPanning} + onPointerUp={endFloorplanNavigation} ref={svgRef} - style={{ cursor: referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR }} + style={{ + cursor: + floorplanNavigationCursor ?? (referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR), + }} viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`} > @@ -9346,6 +9998,17 @@ export function FloorplanPanel() { /> )} + {isFloorplanNavigationOverlayVisible && ( + + )} )} diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index d9b30c1f..54f517a5 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -1,12 +1,19 @@ 'use client' -import { type AnyNode, type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + useLiveNodeOverrides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { CORNER_OFFSET, classifyParticipant, @@ -41,7 +48,10 @@ export function GroupMoveHandle() { const nodes = useScene((s) => s.nodes) const participantIds = useMemo( - () => selectedIds.filter((id) => classifyParticipant(nodes[id as AnyNodeId], levelId) !== null), + () => + selectedIds.filter( + (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, + ), [selectedIds, levelId, nodes], ) @@ -103,6 +113,7 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { const activate = (event: ThreeEvent) => { event.stopPropagation() + suppressBoxSelectForPointer(event) frozenCorner.current = rest.corner.clone() const planeY = rest.baseY @@ -159,18 +170,28 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { lastSnap = [dx, dz] } - const overrides = useLiveNodeOverrides.getState() + const overrideEntries: Array]> = [] + const liveTransforms = useLiveTransforms.getState() for (const s of starts) { if (s.kind === 'endpoint') { - overrides.set(s.id, { - start: [s.start[0] + dx, s.start[1] + dz], - end: [s.end[0] + dx, s.end[1] + dz], - }) + overrideEntries.push([ + s.id, + { + start: [s.start[0] + dx, s.start[1] + dz], + end: [s.end[0] + dx, s.end[1] + dz], + }, + ]) } else { // Slide on the floor: XZ shift, Y and rotation untouched. - overrides.set(s.id, { - position: [s.position[0] + dx, s.position[1], s.position[2] + dz], - }) + const position: [number, number, number] = [ + s.position[0] + dx, + s.position[1], + s.position[2] + dz, + ] + overrideEntries.push([s.id, { position }]) + if (s.kind === 'scalar') { + liveTransforms.set(s.id, { position, rotation: s.rotation }) + } } useScene.getState().markDirty(s.id) } @@ -178,16 +199,31 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { // Shared endpoints of connected neighbours follow by the same delta so // the junction stays welded; the far end stays put. for (const l of links) { - overrides.set(l.id, { - start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start, - end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end, - }) + overrideEntries.push([ + l.id, + { + start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start, + end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end, + }, + ]) useScene.getState().markDirty(l.id) } + useLiveNodeOverrides.getState().setMany(overrideEntries) setLiveDelta([dx, dz]) } + const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] + const clearLivePreviews = () => { + const overrides = useLiveNodeOverrides.getState() + const liveTransforms = useLiveTransforms.getState() + for (const id of affectedIds) { + overrides.clear(id) + liveTransforms.clear(id) + useScene.getState().markDirty(id) + } + } + const cleanup = () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) @@ -201,8 +237,6 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { dragCleanupRef.current = null } - const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] - const commitFromOverrides = () => { const overrides = useLiveNodeOverrides.getState() const updates: { id: AnyNodeId; data: Partial }[] = [] @@ -223,22 +257,22 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { // tracked set — collapsing the whole group move into one undo. useScene.temporal.getState().resume() if (updates.length > 0) useScene.getState().updateNodes(updates) - for (const id of affectedIds) { - useLiveNodeOverrides.getState().clear(id) - useScene.getState().markDirty(id) - } + clearLivePreviews() cleanup() } const onCancel = () => { - for (const id of affectedIds) { - useLiveNodeOverrides.getState().clear(id) - useScene.getState().markDirty(id) - } + clearLivePreviews() cleanup() } - dragCleanupRef.current = cleanup + dragCleanupRef.current = () => { + clearLivePreviews() + cleanup() + } + for (const id of affectedIds) { + useLiveTransforms.getState().clear(id) + } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index f58d26d8..671ebbdf 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -1,12 +1,19 @@ 'use client' -import { type AnyNode, type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + useLiveNodeOverrides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { CORNER_OFFSET, classifyParticipant, @@ -35,11 +42,11 @@ import { const ROTATE_SNAP = Math.PI / 12 // 15° /** - * Group-rotate gizmo. When 2+ "movable" nodes (position + rotation, sitting - * directly on the active level) are selected, a single rotation handle appears - * at the selection's bounding-box center. Dragging it spins every selected node - * rigidly around that shared center — orbiting each node's position AND turning - * its yaw by the same delta, so the group rotates as one piece. + * Group-rotate gizmo. When 2+ transformable nodes in the active level frame are + * selected, a single rotation handle appears at the selection's bounding-box + * center. Dragging it spins every selected node rigidly around that shared + * center — orbiting each node's position AND turning its yaw by the same delta, + * so the group rotates as one piece. * * The single-selection case is handled by `NodeArrowHandles`; a full-level * box-select promotes to a building selection, so neither reaches this gizmo. @@ -55,7 +62,10 @@ export function GroupRotateHandle() { const nodes = useScene((s) => s.nodes) const participantIds = useMemo( - () => selectedIds.filter((id) => classifyParticipant(nodes[id as AnyNodeId], levelId) !== null), + () => + selectedIds.filter( + (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, + ), [selectedIds, levelId, nodes], ) @@ -123,6 +133,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { const activate = (event: ThreeEvent) => { event.stopPropagation() + suppressBoxSelectForPointer(event) frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() } const center = rest.pivot.clone() @@ -200,10 +211,14 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { const dz = z - center.z return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos] } - const overrides = useLiveNodeOverrides.getState() + const overrideEntries: Array]> = [] + const liveTransforms = useLiveTransforms.getState() for (const s of starts) { if (s.kind === 'endpoint') { - overrides.set(s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }) + overrideEntries.push([ + s.id, + { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }, + ]) } else { const [px, pz] = rot(s.position[0], s.position[2]) const position: Vec3 = [px, s.position[1], pz] @@ -211,7 +226,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { s.kind === 'vec3' ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3) : s.rotation - delta - overrides.set(s.id, { position, rotation }) + overrideEntries.push([s.id, { position, rotation }]) + if (s.kind === 'scalar') { + liveTransforms.set(s.id, { position, rotation: s.rotation - delta }) + } } useScene.getState().markDirty(s.id) } @@ -220,12 +238,16 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { // (rot is deterministic, so it lands exactly on the selected wall's // rotated endpoint), keeping the junction welded; the far end stays put. for (const l of links) { - overrides.set(l.id, { - start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start, - end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end, - }) + overrideEntries.push([ + l.id, + { + start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start, + end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end, + }, + ]) useScene.getState().markDirty(l.id) } + useLiveNodeOverrides.getState().setMany(overrideEntries) if (Math.abs(delta) < 0.0087) { setGuide(null) @@ -247,6 +269,17 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { } } + const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] + const clearLivePreviews = () => { + const overrides = useLiveNodeOverrides.getState() + const liveTransforms = useLiveTransforms.getState() + for (const id of affectedIds) { + overrides.clear(id) + liveTransforms.clear(id) + useScene.getState().markDirty(id) + } + } + const cleanup = () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) @@ -260,8 +293,6 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { dragCleanupRef.current = null } - const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] - const commitFromOverrides = () => { const overrides = useLiveNodeOverrides.getState() const updates: { id: AnyNodeId; data: Partial }[] = [] @@ -282,23 +313,23 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { // one tracked set — collapsing the whole group rotation into one undo. useScene.temporal.getState().resume() if (updates.length > 0) useScene.getState().updateNodes(updates) - for (const id of affectedIds) { - useLiveNodeOverrides.getState().clear(id) - useScene.getState().markDirty(id) - } + clearLivePreviews() cleanup() } const onCancel = () => { // Revert: drop overrides + mark dirty so renderers rebuild from the store. - for (const id of affectedIds) { - useLiveNodeOverrides.getState().clear(id) - useScene.getState().markDirty(id) - } + clearLivePreviews() cleanup() } - dragCleanupRef.current = cleanup + dragCleanupRef.current = () => { + clearLivePreviews() + cleanup() + } + for (const id of affectedIds) { + useLiveTransforms.getState().clear(id) + } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) diff --git a/packages/editor/src/components/editor/group-transform-shared.test.ts b/packages/editor/src/components/editor/group-transform-shared.test.ts new file mode 100644 index 00000000..6e1ae5e1 --- /dev/null +++ b/packages/editor/src/components/editor/group-transform-shared.test.ts @@ -0,0 +1,205 @@ +import { beforeAll, describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core' +import { z } from 'zod' +import { classifyParticipant, collectParticipants } from './group-transform-shared' + +const BUILDING_SCOPED_KIND = 'group-transform-building-scoped-test' + +function registerBuildingScopedTestKind() { + if (nodeRegistry.has(BUILDING_SCOPED_KIND)) return + + registerNode({ + kind: BUILDING_SCOPED_KIND, + schemaVersion: 1, + schema: z.object({ type: z.literal(BUILDING_SCOPED_KIND) }) as never, + category: 'structure', + defaults: () => ({}), + capabilities: {}, + floorplanScope: 'building', + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition) +} + +function registerElevatorTestKind() { + if (nodeRegistry.has('elevator')) return + + registerNode({ + kind: 'elevator', + schemaVersion: 1, + schema: z.object({ type: z.literal('elevator') }) as never, + category: 'structure', + defaults: () => ({}), + capabilities: { selectable: {} }, + floorplanScope: 'building', + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition) +} + +describe('group transform participants', () => { + beforeAll(() => { + registerBuildingScopedTestKind() + registerElevatorTestKind() + }) + + test('includes building-scoped positioned nodes for the active level building', () => { + const nodes = { + building_test: { + id: 'building_test', + type: 'building', + children: ['level_test', 'elevator_test'], + }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_test', + children: [], + }, + elevator_test: { + id: 'elevator_test', + type: BUILDING_SCOPED_KIND, + parentId: 'building_test', + position: [1, 0, 2], + rotation: 0, + }, + } as unknown as Record + + expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar') + + const participants = collectParticipants(['elevator_test'], nodes, 'level_test') + expect(participants.starts).toEqual([ + { + id: 'elevator_test', + kind: 'scalar', + position: [1, 0, 2], + rotation: 0, + }, + ]) + }) + + test('excludes building-scoped positioned nodes from other buildings', () => { + const nodes = { + building_active: { + id: 'building_active', + type: 'building', + children: ['level_test'], + }, + building_other: { + id: 'building_other', + type: 'building', + children: ['elevator_test'], + }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_active', + children: [], + }, + elevator_test: { + id: 'elevator_test', + type: BUILDING_SCOPED_KIND, + parentId: 'building_other', + position: [1, 0, 2], + rotation: 0, + }, + } as unknown as Record + + expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBeNull() + expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([]) + }) + + test('uses current elevator defaults for legacy elevators with no saved rotation', () => { + const nodes = { + building_test: { + id: 'building_test', + type: 'building', + children: ['level_test', 'elevator_test'], + }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_test', + children: [], + }, + elevator_test: { + id: 'elevator_test', + type: 'elevator', + parentId: 'building_test', + position: [3, 0, 4], + }, + } as unknown as Record + + expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar') + expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([ + { + id: 'elevator_test', + kind: 'scalar', + position: [3, 0, 4], + rotation: 0, + }, + ]) + }) + + test('resolves building-scoped elevators when legacy level parentId is missing', () => { + const nodes = { + building_test: { + id: 'building_test', + type: 'building', + children: ['level_test', 'elevator_test'], + }, + level_test: { + id: 'level_test', + type: 'level', + parentId: null, + children: [], + }, + elevator_test: { + id: 'elevator_test', + type: 'elevator', + parentId: 'building_test', + position: [7, 0, 8], + }, + } as unknown as Record + + expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar') + expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([ + { + id: 'elevator_test', + kind: 'scalar', + position: [7, 0, 8], + rotation: 0, + }, + ]) + }) + + test('supports legacy level-parented elevators already loaded in the editor', () => { + const nodes = { + building_test: { + id: 'building_test', + type: 'building', + children: ['level_test'], + }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_test', + children: ['elevator_test'], + }, + elevator_test: { + id: 'elevator_test', + type: 'elevator', + parentId: 'level_test', + position: [5, 0, 6], + }, + } as unknown as Record + + expect(classifyParticipant(nodes.elevator_test, 'level_test', nodes)).toBe('scalar') + expect(collectParticipants(['elevator_test'], nodes, 'level_test').starts).toEqual([ + { + id: 'elevator_test', + kind: 'scalar', + position: [5, 0, 6], + rotation: 0, + }, + ]) + }) +}) diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts index 868db0b2..8252d3f9 100644 --- a/packages/editor/src/components/editor/group-transform-shared.ts +++ b/packages/editor/src/components/editor/group-transform-shared.ts @@ -1,4 +1,10 @@ -import { type AnyNode, type AnyNodeId, sceneRegistry } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + nodeRegistry, + resolveBuildingForLevel, + sceneRegistry, +} from '@pascal-app/core' import { Box3 } from 'three' // Shared plumbing for the group transform gizmos (rotate + move). Both operate @@ -26,20 +32,61 @@ const isVec2 = (v: unknown): v is Vec2 => // - 'endpoint' start/end tuples (walls, fences) export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint' -// A selected node qualifies when it sits directly on the active level and its -// placement is one of the transformable shapes. Doors/windows parent to their -// wall (not the level), so they're excluded here and ride their wall. +// A selected node qualifies when it belongs to the active level's horizontal +// frame: either parented to that level, or declared building-scoped and parented +// to the active level's building. Doors/windows parent to their wall, so they're +// excluded here and ride their wall. +function isInGroupTransformScope( + node: AnyNode | undefined, + levelId: string | null, + sceneNodes: Record, +): boolean { + if (!node || !levelId) return false + if (node.parentId === levelId) return true + + if (nodeRegistry.get(node.type)?.floorplanScope !== 'building') { + return false + } + + const buildingId = resolveBuildingForLevel( + levelId as AnyNodeId, + sceneNodes as Record, + ) + return Boolean(buildingId && node.parentId === buildingId) +} + +function getLegacyScenePosition(node: AnyNode): Vec3 | null { + if (node.type !== 'elevator') return null + const object = sceneRegistry.nodes.get(node.id) + if (!object) return [0, 0, 0] + return [object.position.x, object.position.y, object.position.z] +} + +function getParticipantPosition(node: AnyNode): Vec3 | null { + const p = (node as { position?: unknown }).position + if (isVec3(p)) return p + return getLegacyScenePosition(node) +} + +function getParticipantScalarRotation(node: AnyNode): number | null { + const r = (node as { rotation?: unknown }).rotation + if (typeof r === 'number' && Number.isFinite(r)) return r + if (node.type !== 'elevator') return null + return sceneRegistry.nodes.get(node.id)?.rotation.y ?? 0 +} + export function classifyParticipant( node: AnyNode | undefined, levelId: string | null, + sceneNodes: Record, ): ParticipantKind | null { - if (!node || node.parentId !== levelId) return null - const p = (node as { position?: unknown }).position + if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null + const p = getParticipantPosition(node) const r = (node as { rotation?: unknown }).rotation const start = (node as { start?: unknown }).start const end = (node as { end?: unknown }).end if (isVec3(p) && isVec3(r)) return 'vec3' - if (isVec3(p) && typeof r === 'number') return 'scalar' + if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar' if (isVec2(start) && isVec2(end)) return 'endpoint' return null } @@ -74,23 +121,27 @@ export function collectParticipants( const starts: ParticipantStart[] = [] for (const id of ids) { const node = sceneNodes[id] - const kind = classifyParticipant(node, levelId) + const kind = classifyParticipant(node, levelId, sceneNodes) if (!node || !kind) continue if (kind === 'vec3') { const n = node as AnyNode & { position: Vec3; rotation: Vec3 } + const position = getParticipantPosition(node) + if (!position) continue starts.push({ id: id as AnyNodeId, kind, - position: [n.position[0], n.position[1], n.position[2]], + position: [position[0], position[1], position[2]], rotation: [n.rotation[0], n.rotation[1], n.rotation[2]], }) } else if (kind === 'scalar') { - const n = node as AnyNode & { position: Vec3; rotation: number } + const position = getParticipantPosition(node) + const rotation = getParticipantScalarRotation(node) + if (!(position && rotation !== null)) continue starts.push({ id: id as AnyNodeId, kind, - position: [n.position[0], n.position[1], n.position[2]], - rotation: n.rotation, + position: [position[0], position[1], position[2]], + rotation, }) } else { const n = node as AnyNode & { start: Vec2; end: Vec2 } @@ -112,7 +163,7 @@ export function collectParticipants( const selected = new Set(starts.map((s) => s.id)) for (const [nid, node] of Object.entries(sceneNodes)) { if (selected.has(nid as AnyNodeId)) continue - if (classifyParticipant(node, levelId) !== 'endpoint') continue + if (classifyParticipant(node, levelId, sceneNodes) !== 'endpoint') continue const n = node as AnyNode & { start: Vec2; end: Vec2 } const start: Vec2 = [n.start[0], n.start[1]] const end: Vec2 = [n.end[0], n.end[1]] @@ -138,7 +189,7 @@ export function expandToComponent( ): string[] { const endpoints: { id: string; start: Vec2; end: Vec2 }[] = [] for (const [id, node] of Object.entries(sceneNodes)) { - if (classifyParticipant(node, levelId) === 'endpoint') { + if (classifyParticipant(node, levelId, sceneNodes) === 'endpoint') { const n = node as AnyNode & { start: Vec2; end: Vec2 } endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] }) } diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index 574e95ba..758bee54 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -13,6 +13,7 @@ import { type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { type Camera, type Object3D, type Plane, Vector2, type Vector3 } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' +import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' export type HandleDragControls = { onStart: (index: number, snapshot: AnyNode) => void @@ -77,6 +78,26 @@ export function swallowNextClick() { }, 300) } +function suppressInputDraggingUntilPointerRelease(pointerId: number) { + const previousInputDragging = useViewer.getState().inputDragging + useViewer.getState().setInputDragging(true) + + function restore(event?: PointerEvent) { + if (event && event.pointerId !== pointerId) return + useViewer.getState().setInputDragging(previousInputDragging) + window.removeEventListener('pointerup', restore) + window.removeEventListener('pointercancel', restore) + window.removeEventListener('blur', onBlur) + } + function onBlur() { + restore() + } + + window.addEventListener('pointerup', restore) + window.addEventListener('pointercancel', restore) + window.addEventListener('blur', onBlur) +} + export function useHandleDrag(args: UseHandleDragArgs) { const { camera, raycaster, gl } = useThree() const dragCleanupRef = useRef<(() => void) | null>(null) @@ -85,8 +106,11 @@ export function useHandleDrag(args: UseHandleDragArgs) { return (event: ThreeEvent) => { event.stopPropagation() + suppressBoxSelectForPointer(event) if (args.kind === 'tap') { + suppressInputDraggingUntilPointerRelease(event.nativeEvent.pointerId) + swallowNextClick() sfxEmitter.emit('sfx:item-pick') document.body.style.cursor = '' args.onTap(event) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 5c6e5d75..c8e684de 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -341,6 +341,7 @@ const EDITOR_CAMERA_CONTROL_HINTS: CameraControlHint[] = [ { action: 'Pan', keys: [{ value: 'Space' }, { value: 'Left click' }], + alternativeKeys: [{ value: 'Middle click' }], }, { action: 'Rotate', keys: [{ value: 'Right click' }] }, { action: 'Zoom', keys: [{ value: 'Scroll' }] }, diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index c8bef3f3..04c7dab7 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -125,6 +125,7 @@ export function NodeArrowHandles() { const mode = useEditor((state) => state.mode) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const movingNode = useEditor((state) => state.movingNode) + const placementDragMode = useEditor((state) => state.placementDragMode) // Endpoint / curve drags reshape the selected wall or fence; hide its // resize arrows for the duration so they don't clutter (or get blocked // by) the drag's own cursor + dimension overlays. Mirrors the same guard @@ -150,6 +151,8 @@ export function NodeArrowHandles() { () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode), [rawNode, liveOverride], ) + const isOwnPressDragMove = + placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId const def = node ? nodeRegistry.get(node.type) : null const descriptors = useMemo(() => { @@ -163,7 +166,7 @@ export function NodeArrowHandles() { Boolean(node && descriptors?.length) && !isFloorplanHovered && mode !== 'delete' && - !movingNode && + (!movingNode || isOwnPressDragMove) && !movingWallEndpoint && !movingFenceEndpoint && !curvingWall && diff --git a/packages/editor/src/components/editor/slab-hole-highlights.tsx b/packages/editor/src/components/editor/slab-hole-highlights.tsx index 4fff543b..dfce55ae 100644 --- a/packages/editor/src/components/editor/slab-hole-highlights.tsx +++ b/packages/editor/src/components/editor/slab-hole-highlights.tsx @@ -26,6 +26,7 @@ import { import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import useEditor from '../../store/use-editor' +import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { swallowNextClick } from './handles/use-handle-drag' const ACCENT = 0x83_81_ed @@ -199,6 +200,7 @@ function resetPointerCursor() { function stopPointerPropagation(event: ThreeEvent) { event.stopPropagation() + suppressBoxSelectForPointer(event) event.nativeEvent.stopPropagation() event.nativeEvent.stopImmediatePropagation() } diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 40d0beac..1840e459 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -34,6 +34,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { MeshBasicNodeMaterial } from 'three/webgpu' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { createArrowHitAreaGeometry, createEndpointHitAreaGeometry, @@ -329,6 +330,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: const activateEndpointMove = (event: ThreeEvent) => { event.stopPropagation() + suppressBoxSelectForPointer(event) sfxEmitter.emit('sfx:item-pick') document.body.style.cursor = 'grabbing' useEditor.getState().setMovingWallEndpoint({ wall, endpoint }) @@ -432,6 +434,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const activateHeightResize = (event: ThreeEvent) => { event.stopPropagation() + suppressBoxSelectForPointer(event) const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null if (!levelObject) return @@ -603,6 +606,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov const activateWallMove = (event: ThreeEvent) => { event.stopPropagation() + suppressBoxSelectForPointer(event) document.body.style.cursor = 'grabbing' sfxEmitter.emit('sfx:item-pick') @@ -693,6 +697,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal const activateFenceMove = (event: ThreeEvent) => { event.stopPropagation() + suppressBoxSelectForPointer(event) document.body.style.cursor = 'grabbing' sfxEmitter.emit('sfx:item-pick') 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 4fcadbfb..b864a68f 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 @@ -22,10 +22,14 @@ import { import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' +import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' +import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' +import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility' import { PlacementBox } from '../shared/placement-box' /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 @@ -155,6 +159,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ) const [valid, setValid] = useState(true) const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) + const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } = + useFreshPlacementVisibility({ node }) // Mirrors of `valid` / Shift for the event handlers inside the effect, which // can't read React state without stale closures. const validRef = useRef(true) @@ -180,6 +186,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { setCursorRotationY(originalRotationY) lastCursorRef.current = originalPosition let committed = false + const isNew = isFreshPlacement const baseRotation = (node as { rotation?: unknown }).rotation const toCommitRotation = (y: number): number | [number, number, number] => @@ -271,11 +278,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const onGridMove = (event: GridEvent) => { const rawX = event.localPosition[0] const rawZ = event.localPosition[2] - const anchor = dragAnchorRef.current ?? [rawX, rawZ] - dragAnchorRef.current = anchor + revealFreshPlacement() - let x = originalPosition[0] + snapToGridStep(rawX - anchor[0]) - let z = originalPosition[2] + snapToGridStep(rawZ - anchor[1]) + const resolved = resolvePlanarCursorPosition({ + cursor: [rawX, rawZ], + original: [originalPosition[0], originalPosition[2]], + anchor: dragAnchorRef.current, + mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative', + snap: snapToGridStep, + }) + dragAnchorRef.current = resolved.anchor + let [x, z] = resolved.point // Figma-style alignment snap layered on top of grid snap: when the // moving item's edge lines up (on X or Z) with another item's edge, @@ -358,12 +371,32 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const rotation = toCommitRotation(rotationRef.current) const visualPosition = getVisualPosition(position) + let committedId = node.id as AnyNodeId if (useScene.getState().nodes[node.id]) { - useScene.temporal.getState().resume() - useScene.getState().updateNode(node.id, { position, rotation } as Partial) - useScene.temporal.getState().pause() - committed = true + const data = { + position, + rotation, + ...(isNew + ? { + metadata: stripPlacementMetadataFlags(node.metadata), + visible: true, + } + : null), + } as Partial + + if (isNew) { + const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data) + if (finalId) { + committed = true + committedId = finalId + } + } else { + useScene.temporal.getState().resume() + useScene.getState().updateNode(node.id, data) + useScene.temporal.getState().pause() + committed = true + } } else if (node.parentId) { // Orphan re-create path: re-parse via the registry's schema. const def = nodeRegistry.get(node.type) @@ -393,8 +426,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } useAlignmentGuides.getState().clear() + if (isNew && committed) { + useViewer.getState().setSelection({ selectedIds: [committedId] }) + } sfxEmitter.emit('sfx:item-place') + useEditor.getState().setMovingNodeOrigin('3d') exitMoveMode() // Stop further propagation so other listeners (e.g. a selection @@ -470,13 +507,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const onCancel = () => { useLiveTransforms.getState().clear(node.id) - const m = sceneRegistry.nodes.get(node.id) - if (m) { - m.position.set(...getVisualPosition(originalPosition, originalRotationY)) - m.rotation.y = originalRotationY + if (isNew) { + useScene.getState().deleteNode(node.id as AnyNodeId) + } else { + const m = sceneRegistry.nodes.get(node.id) + if (m) { + m.position.set(...getVisualPosition(originalPosition, originalRotationY)) + m.rotation.y = originalRotationY + } + markMovedNodeDirty() } useAlignmentGuides.getState().clear() - markMovedNodeDirty() useScene.temporal.getState().resume() markToolCancelConsumed() exitMoveMode() @@ -499,16 +540,28 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Drop any alignment guides this drag published — covers Esc / mid-drag // unmount / commit paths uniformly. useAlignmentGuides.getState().clear() - if (!committed) { + const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d' + if (!(committed || isNew || finalisedBy2D)) { useLiveTransforms.getState().clear(node.id) sceneRegistry.nodes .get(node.id) ?.position.set(...getVisualPosition(originalPosition, originalRotationY)) markMovedNodeDirty() - useScene.temporal.getState().resume() } + useScene.temporal.getState().resume() } - }, [boxDimensions, exitMoveMode, node, originalPosition, originalRotationY]) + }, [ + boxDimensions, + exitMoveMode, + isFreshPlacement, + node, + originalPosition, + originalRotationY, + revealFreshPlacement, + useAbsoluteCursorPlacement, + ]) + + if (!previewVisible) return null if (boxDimensions) { return ( diff --git a/packages/editor/src/components/tools/select/box-select-state.ts b/packages/editor/src/components/tools/select/box-select-state.ts index cacdb50f..a3c2ca4d 100644 --- a/packages/editor/src/components/tools/select/box-select-state.ts +++ b/packages/editor/src/components/tools/select/box-select-state.ts @@ -1,6 +1,21 @@ export let boxSelectHandled = false let resetTimeout: ReturnType | null = null +const suppressedPointerIds = new Set() +const suppressionCleanups = new Map void>() + +type PointerEventLike = { + pointerId?: number + nativeEvent?: PointerEvent | PointerEventLike +} + +function pointerIdFor(event: PointerEvent | PointerEventLike): number | null { + if ('pointerId' in event && typeof event.pointerId === 'number') { + return event.pointerId + } + const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : undefined + return nativeEvent ? pointerIdFor(nativeEvent) : null +} export function markBoxSelectHandled() { boxSelectHandled = true @@ -13,10 +28,50 @@ export function markBoxSelectHandled() { }, 50) } +export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLike) { + markBoxSelectHandled() + + const pointerId = pointerIdFor(event) + if (pointerId === null || suppressedPointerIds.has(pointerId)) return + + suppressedPointerIds.add(pointerId) + + const clear = (releaseEvent?: PointerEvent) => { + if (releaseEvent && releaseEvent.pointerId !== pointerId) return + markBoxSelectHandled() + suppressedPointerIds.delete(pointerId) + const cleanup = suppressionCleanups.get(pointerId) + suppressionCleanups.delete(pointerId) + cleanup?.() + } + + const onPointerUp = (releaseEvent: PointerEvent) => clear(releaseEvent) + const onPointerCancel = (releaseEvent: PointerEvent) => clear(releaseEvent) + const onBlur = () => clear() + const cleanup = () => { + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onBlur) + } + + suppressionCleanups.set(pointerId, cleanup) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('blur', onBlur) +} + +export function isBoxSelectPointerSuppressed(event: PointerEvent | PointerEventLike) { + const pointerId = pointerIdFor(event) + return pointerId !== null && suppressedPointerIds.has(pointerId) +} + export function clearBoxSelectHandled() { if (resetTimeout) { clearTimeout(resetTimeout) resetTimeout = null } boxSelectHandled = false + for (const cleanup of suppressionCleanups.values()) cleanup() + suppressionCleanups.clear() + suppressedPointerIds.clear() } 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 62f29992..05817dd5 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -4,17 +4,25 @@ import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' import { Box3, type Camera, type Object3D, Vector3 } from 'three' import useEditor from '../../../store/use-editor' -import { clearBoxSelectHandled, markBoxSelectHandled } from './box-select-state' +import { + clearBoxSelectHandled, + isBoxSelectPointerSuppressed, + markBoxSelectHandled, +} from './box-select-state' import { PlaneBoxSelectTool } from './plane-box-select-tool' +import { + createScreenRectangleSelectionElement, + hideScreenRectangleSelectionElement, + intersectScreenRects, + normalizeScreenRect, + SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX, + type ScreenRect, + screenRectFromDomRect, + screenRectsIntersect, + updateScreenRectangleSelectionElement, +} from './screen-rectangle-selection' import { collectSelectableCandidateIds } from './select-candidates' -type ScreenRect = { minX: number; minY: number; maxX: number; maxY: number } - -const BOX_SELECT_FILL_COLOR = 'rgba(129, 140, 248, 0.14)' -const BOX_SELECT_BORDER_COLOR = 'rgba(129, 140, 248, 0.9)' -const BOX_SELECT_SHADOW_COLOR = 'rgba(129, 140, 248, 0.28)' -const DRAG_THRESHOLD_PX = 4 - const tempBox = new Box3() const tempWorldPoint = new Vector3() const tempScreenPoint = new Vector3() @@ -36,76 +44,6 @@ function haveSameIds(currentIds: string[], nextIds: string[]): boolean { ) } -function createSelectionElement(): HTMLDivElement { - const element = document.createElement('div') - element.style.position = 'fixed' - element.style.display = 'none' - element.style.pointerEvents = 'none' - element.style.zIndex = '2147483647' - element.style.border = `1px solid ${BOX_SELECT_BORDER_COLOR}` - element.style.background = BOX_SELECT_FILL_COLOR - element.style.boxShadow = `0 0 0 1px ${BOX_SELECT_SHADOW_COLOR} inset` - element.style.contain = 'layout paint style' - return element -} - -function normalizeScreenRect( - startX: number, - startY: number, - endX: number, - endY: number, -): ScreenRect { - return { - minX: Math.min(startX, endX), - minY: Math.min(startY, endY), - maxX: Math.max(startX, endX), - maxY: Math.max(startY, endY), - } -} - -function updateSelectionElement(element: HTMLDivElement, rect: ScreenRect) { - element.style.display = 'block' - element.style.left = `${rect.minX}px` - element.style.top = `${rect.minY}px` - element.style.width = `${Math.max(0, rect.maxX - rect.minX)}px` - element.style.height = `${Math.max(0, rect.maxY - rect.minY)}px` -} - -function hideSelectionElement(element: HTMLDivElement | null) { - if (!element) return - element.style.display = 'none' - element.style.width = '0px' - element.style.height = '0px' -} - -function screenRectsIntersect(a: ScreenRect, b: ScreenRect): boolean { - return !(b.maxX < a.minX || b.minX > a.maxX || b.maxY < a.minY || b.minY > a.maxY) -} - -function screenRectFromDomRect(rect: DOMRect): ScreenRect { - return { - minX: rect.left, - minY: rect.top, - maxX: rect.right, - maxY: rect.bottom, - } -} - -function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | null { - const rect = { - minX: Math.max(a.minX, b.minX), - minY: Math.max(a.minY, b.minY), - maxX: Math.min(a.maxX, b.maxX), - maxY: Math.min(a.maxY, b.maxY), - } - - if (rect.maxX <= rect.minX || rect.maxY <= rect.minY) { - return null - } - - return rect -} - function projectWorldPointToScreen( point: Vector3, camera: Camera, @@ -268,7 +206,7 @@ const ScreenRectangleSelectTool: React.FC = () => { pointerDownRef.current = false isDraggingRef.current = false pointerIdRef.current = null - hideSelectionElement(elementRef.current) + hideScreenRectangleSelectionElement(elementRef.current) syncPreviewSelectedIds([]) if (ownsInputDraggingRef.current) { @@ -278,7 +216,7 @@ const ScreenRectangleSelectTool: React.FC = () => { }, [syncPreviewSelectedIds]) useEffect(() => { - const element = createSelectionElement() + const element = createScreenRectangleSelectionElement() document.body.appendChild(element) elementRef.current = element @@ -331,6 +269,7 @@ const ScreenRectangleSelectTool: React.FC = () => { const viewer = useViewer.getState() if ( + isBoxSelectPointerSuppressed(event) || spaceDownRef.current || viewer.cameraDragging || (viewer.inputDragging && !ownsInputDraggingRef.current) @@ -348,7 +287,7 @@ const ScreenRectangleSelectTool: React.FC = () => { currentClientYRef.current - startClientYRef.current, ) - if (!isDraggingRef.current && dragDistance >= DRAG_THRESHOLD_PX) { + if (!isDraggingRef.current && dragDistance >= SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX) { isDraggingRef.current = true ownsInputDraggingRef.current = true useViewer.getState().setInputDragging(true) @@ -372,12 +311,12 @@ const ScreenRectangleSelectTool: React.FC = () => { screenRectFromDomRect(canvas.getBoundingClientRect()), ) if (!clampedRect) { - hideSelectionElement(elementRef.current) + hideScreenRectangleSelectionElement(elementRef.current) syncPreviewSelectedIds([]) return } - updateSelectionElement(elementRef.current!, clampedRect) + updateScreenRectangleSelectionElement(elementRef.current!, clampedRect) syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas)) } @@ -385,7 +324,10 @@ const ScreenRectangleSelectTool: React.FC = () => { if (!pointerDownRef.current) return if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return - if (useViewer.getState().inputDragging && !ownsInputDraggingRef.current) { + if ( + isBoxSelectPointerSuppressed(event) || + (useViewer.getState().inputDragging && !ownsInputDraggingRef.current) + ) { markBoxSelectHandled() resetDrag() return @@ -420,6 +362,7 @@ const ScreenRectangleSelectTool: React.FC = () => { const onCanvasPointerDown = (event: PointerEvent) => { if (event.button !== 0) return if (spaceDownRef.current) return + if (isBoxSelectPointerSuppressed(event)) return const viewer = useViewer.getState() if (viewer.cameraDragging || viewer.inputDragging) return diff --git a/packages/editor/src/components/tools/select/plane-box-select-tool.tsx b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx index 63b75cf2..98e91ec7 100644 --- a/packages/editor/src/components/tools/select/plane-box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx @@ -31,7 +31,7 @@ import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' -import { markBoxSelectHandled } from './box-select-state' +import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state' import { collectSelectableCandidateIds } from './select-candidates' declare module 'react/jsx-runtime' { @@ -407,6 +407,7 @@ export const PlaneBoxSelectTool: React.FC = () => { const onCanvasPointerDown = (event: PointerEvent) => { if (event.button !== 0) return if (spaceDownRef.current) return + if (isBoxSelectPointerSuppressed(event)) return if (useViewer.getState().cameraDragging) return if (useViewer.getState().inputDragging) return @@ -426,7 +427,8 @@ export const PlaneBoxSelectTool: React.FC = () => { const onCanvasPointerUp = (event: PointerEvent) => { if (event.button !== 0) return - if (useViewer.getState().inputDragging) { + if (isBoxSelectPointerSuppressed(event) || useViewer.getState().inputDragging) { + markBoxSelectHandled() resetDrag() return } @@ -494,7 +496,16 @@ export const PlaneBoxSelectTool: React.FC = () => { } if (!pointerDown.current) return - if (spaceDownRef.current || useViewer.getState().inputDragging) return + if (isBoxSelectPointerSuppressed(event.nativeEvent)) { + markBoxSelectHandled() + resetDrag() + return + } + if (spaceDownRef.current || useViewer.getState().inputDragging) { + markBoxSelectHandled() + resetDrag() + return + } currentPoint.current.set(snappedX, event.position[1], snappedZ) @@ -538,7 +549,7 @@ export const PlaneBoxSelectTool: React.FC = () => { return () => { emitter.off('grid:move', onMove) } - }, [syncPreviewSelectedIds]) + }, [resetDrag, syncPreviewSelectedIds]) return ( diff --git a/packages/editor/src/components/tools/select/screen-rectangle-selection.ts b/packages/editor/src/components/tools/select/screen-rectangle-selection.ts new file mode 100644 index 00000000..c4cd8b62 --- /dev/null +++ b/packages/editor/src/components/tools/select/screen-rectangle-selection.ts @@ -0,0 +1,84 @@ +export type ScreenRect = { + minX: number + minY: number + maxX: number + maxY: number +} + +export const SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX = 4 + +const SCREEN_RECTANGLE_SELECTION_FILL_COLOR = 'rgba(129, 140, 248, 0.14)' +const SCREEN_RECTANGLE_SELECTION_BORDER_COLOR = 'rgba(129, 140, 248, 0.9)' +const SCREEN_RECTANGLE_SELECTION_SHADOW_COLOR = 'rgba(129, 140, 248, 0.28)' + +export function createScreenRectangleSelectionElement(): HTMLDivElement { + const element = document.createElement('div') + element.style.position = 'fixed' + element.style.display = 'none' + element.style.pointerEvents = 'none' + element.style.zIndex = '2147483647' + element.style.border = `1px solid ${SCREEN_RECTANGLE_SELECTION_BORDER_COLOR}` + element.style.background = SCREEN_RECTANGLE_SELECTION_FILL_COLOR + element.style.boxShadow = `0 0 0 1px ${SCREEN_RECTANGLE_SELECTION_SHADOW_COLOR} inset` + element.style.contain = 'layout paint style' + return element +} + +export function normalizeScreenRect( + startX: number, + startY: number, + endX: number, + endY: number, +): ScreenRect { + return { + minX: Math.min(startX, endX), + minY: Math.min(startY, endY), + maxX: Math.max(startX, endX), + maxY: Math.max(startY, endY), + } +} + +export function screenRectFromDomRect(rect: DOMRect | DOMRectReadOnly): ScreenRect { + return { + minX: rect.left, + minY: rect.top, + maxX: rect.right, + maxY: rect.bottom, + } +} + +export function screenRectsIntersect(a: ScreenRect, b: ScreenRect): boolean { + return !(b.maxX < a.minX || b.minX > a.maxX || b.maxY < a.minY || b.minY > a.maxY) +} + +export function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | null { + const rect = { + minX: Math.max(a.minX, b.minX), + minY: Math.max(a.minY, b.minY), + maxX: Math.min(a.maxX, b.maxX), + maxY: Math.min(a.maxY, b.maxY), + } + + if (rect.maxX <= rect.minX || rect.maxY <= rect.minY) { + return null + } + + return rect +} + +export function updateScreenRectangleSelectionElement(element: HTMLDivElement, rect: ScreenRect) { + element.style.display = 'block' + element.style.left = `${rect.minX}px` + element.style.top = `${rect.minY}px` + element.style.width = `${Math.max(0, rect.maxX - rect.minX)}px` + element.style.height = `${Math.max(0, rect.maxY - rect.minY)}px` +} + +export function hideScreenRectangleSelectionElement(element: HTMLDivElement | null) { + if (!element) { + return + } + element.style.display = 'none' + element.style.width = '0px' + element.style.height = '0px' +} diff --git a/packages/editor/src/components/tools/select/select-candidates.test.ts b/packages/editor/src/components/tools/select/select-candidates.test.ts new file mode 100644 index 00000000..e4a2351e --- /dev/null +++ b/packages/editor/src/components/tools/select/select-candidates.test.ts @@ -0,0 +1,109 @@ +import { beforeAll, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + nodeRegistry, + registerNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { z } from 'zod' +import useEditor from '../../../store/use-editor' +import { collectSelectableCandidateIds } from './select-candidates' + +function registerSelectableElevatorTestKind() { + if (nodeRegistry.has('elevator')) return + + registerNode({ + kind: 'elevator', + schemaVersion: 1, + schema: z.object({ type: z.literal('elevator') }) as never, + category: 'structure', + defaults: () => ({}), + capabilities: { selectable: {} }, + floorplanScope: 'building', + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition) +} + +describe('selectable candidates', () => { + beforeAll(() => { + registerSelectableElevatorTestKind() + }) + + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useViewer.setState({ + selection: { + buildingId: 'building_test', + levelId: 'level_test', + zoneId: null, + selectedIds: [], + }, + previewSelectedIds: [], + }) + useEditor.setState({ + phase: 'structure', + structureLayer: 'elements', + }) + }) + + test('includes building-scoped elevators for the active level building', () => { + useScene.setState({ + nodes: { + building_test: { + id: 'building_test', + type: 'building', + children: ['level_test', 'elevator_test'], + }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_test', + children: [], + }, + elevator_test: { + id: 'elevator_test', + type: 'elevator', + parentId: 'building_test', + position: [1, 0, 2], + rotation: 0, + }, + } as unknown as Record, + } as never) + + expect(collectSelectableCandidateIds()).toContain('elevator_test') + }) + + test('includes legacy level-parented elevators already loaded in the editor', () => { + useScene.setState({ + nodes: { + building_test: { + id: 'building_test', + type: 'building', + children: ['level_test'], + }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_test', + children: ['elevator_test'], + }, + elevator_test: { + id: 'elevator_test', + type: 'elevator', + parentId: 'level_test', + position: [1, 0, 2], + rotation: 0, + }, + } as unknown as Record, + } as never) + + expect(collectSelectableCandidateIds()).toContain('elevator_test') + }) +}) diff --git a/packages/editor/src/components/tools/select/select-candidates.ts b/packages/editor/src/components/tools/select/select-candidates.ts index 2ef4d371..2f91b80c 100644 --- a/packages/editor/src/components/tools/select/select-candidates.ts +++ b/packages/editor/src/components/tools/select/select-candidates.ts @@ -5,43 +5,15 @@ import { type LevelNode, nodeRegistry, resolveBuildingForLevel, + resolveLevelId, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import useEditor from '../../../store/use-editor' -export function isFurnishSelectableCandidate(node: AnyNode): boolean { - if (node.type === 'item') { - return node.asset.category !== 'door' && node.asset.category !== 'window' - } - - const def = nodeRegistry.get(node.type) - return Boolean(def?.category === 'furnish' && def.capabilities.selectable) -} - -export function isStructureSelectableCandidate(node: AnyNode): boolean { - if ( - node.type === 'wall' || - node.type === 'fence' || - node.type === 'column' || - node.type === 'elevator' || - node.type === 'slab' || - node.type === 'ceiling' || - node.type === 'roof' || - node.type === 'stair' || - node.type === 'spawn' || - node.type === 'window' || - node.type === 'door' - ) { - return true - } - - if (node.type === 'item') { - return node.asset.category === 'door' || node.asset.category === 'window' - } - - const def = nodeRegistry.get(node.type) - return Boolean(def && def.category !== 'furnish' && def.capabilities.selectable) +function isVisibleSelectableNode(node: AnyNode): boolean { + if ((node as { visible?: boolean }).visible === false) return false + return isRegistrySelectable(node.type) } export function collectSelectableCandidateIds(): string[] { @@ -51,10 +23,23 @@ export function collectSelectableCandidateIds(): string[] { const result: string[] = [] const seen = new Set() const addNode = (node: AnyNode | undefined) => { - if (!node || seen.has(node.id)) return + if (!node || seen.has(node.id) || (node as { visible?: boolean }).visible === false) return seen.add(node.id) result.push(node.id) } + const visitLevelDescendant = (id: AnyNodeId) => { + const node = nodes[id] + if (!node || seen.has(node.id) || (node as { visible?: boolean }).visible === false) return + + if (isRegistrySelectable(node.type)) { + addNode(node) + } + + const children = 'children' in node && Array.isArray(node.children) ? node.children : [] + for (const childId of children) { + visitLevelDescendant(childId as AnyNodeId) + } + } if (phase === 'site') { for (const node of Object.values(nodes)) { @@ -76,49 +61,22 @@ export function collectSelectableCandidateIds(): string[] { } for (const childId of levelNode.children) { - const node = nodes[childId as AnyNodeId] - if (!node) continue - - if (phase === 'furnish') { - if (isFurnishSelectableCandidate(node)) addNode(node) - continue - } - - if (node.type === 'wall' || node.type === 'fence') { - addNode(node) - const hostedChildren = 'children' in node && Array.isArray(node.children) ? node.children : [] - for (const hostedChildId of hostedChildren) { - const child = nodes[hostedChildId as AnyNodeId] - if (!child) continue - if ( - child.type === 'window' || - child.type === 'door' || - (child.type === 'item' && - (child.asset.category === 'door' || child.asset.category === 'window')) - ) { - addNode(child) - } - } - continue - } - - if (isStructureSelectableCandidate(node)) { - addNode(node) - } + visitLevelDescendant(childId as AnyNodeId) } const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes) - const buildingNode = buildingId ? nodes[buildingId] : undefined - const buildingChildren = - buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children) - ? (buildingNode.children as AnyNodeId[]) - : [] - for (const childId of buildingChildren) { - const node = nodes[childId] - if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue - if (phase === 'furnish') { - if (isFurnishSelectableCandidate(node)) addNode(node) - } else if (isStructureSelectableCandidate(node)) { + for (const node of Object.values(nodes)) { + if (!node || node.type === 'level' || !isVisibleSelectableNode(node)) continue + + const def = nodeRegistry.get(node.type) + const isBuildingScoped = def?.floorplanScope === 'building' + const parentId = (node as { parentId?: AnyNodeId | null }).parentId + if (isBuildingScoped && buildingId && parentId === buildingId) { + addNode(node) + continue + } + + if (!isBuildingScoped && resolveLevelId(node, nodes) === levelId) { addNode(node) } } diff --git a/packages/editor/src/components/tools/shared/fresh-placement-visibility.ts b/packages/editor/src/components/tools/shared/fresh-placement-visibility.ts new file mode 100644 index 00000000..4b9ac727 --- /dev/null +++ b/packages/editor/src/components/tools/shared/fresh-placement-visibility.ts @@ -0,0 +1,61 @@ +'use client' + +import { type AnyNode, type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' +import { useCallback, useRef, useState } from 'react' +import { isFreshPlacementMetadata } from '../../../lib/placement-metadata' +import useEditor from '../../../store/use-editor' + +type FreshPlacementNode = Pick + +type FreshPlacementVisibilityArgs = { + node: FreshPlacementNode + enabled?: boolean +} + +export function useFreshPlacementVisibility({ + node, + enabled = true, +}: FreshPlacementVisibilityArgs) { + const isFreshPlacement = enabled && isFreshPlacementMetadata(node.metadata) + const useAbsoluteCursorPlacement = isFreshPlacement && !useEditor.getState().placementDragMode + const shouldStartHidden = useAbsoluteCursorPlacement + + const [visibility, setVisibility] = useState(() => ({ + nodeId: node.id, + visible: !shouldStartHidden, + })) + const visibilityRef = useRef(visibility) + const previewVisible = visibility.nodeId === node.id ? visibility.visible : !shouldStartHidden + + const setPreviewVisibleForNode = useCallback( + (visible: boolean) => { + const current = visibilityRef.current + if (current.nodeId === node.id && current.visible === visible) return + const next = { nodeId: node.id, visible } + visibilityRef.current = next + setVisibility(next) + }, + [node.id], + ) + + const revealFreshPlacement = useCallback(() => { + if (!isFreshPlacement) return + setPreviewVisibleForNode(true) + + sceneRegistry.nodes.get(node.id)?.traverse((child) => { + child.visible = true + }) + + const liveNode = useScene.getState().nodes[node.id as AnyNodeId] + if (liveNode?.visible === false) { + useScene.getState().updateNode(node.id as AnyNodeId, { visible: true } as Partial) + } + }, [isFreshPlacement, node.id, setPreviewVisibleForNode]) + + return { + isFreshPlacement, + previewVisible, + revealFreshPlacement, + useAbsoluteCursorPlacement, + } +} diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index f11de2c0..1b525903 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -6,6 +6,7 @@ import { emitter, type GridEvent, type LevelNode, + movingAlignmentAnchors, type NodeEvent, resolveAlignment, StairNode, @@ -295,14 +296,30 @@ export const StairTool: React.FC = () => { } // Alignment candidates — anchors of every alignable object; refreshed - // after each placement. The stair aligns by its ORIGIN point. + // after each placement. The moving stair aligns by its footprint edges so + // users can snap the run side against walls, slabs, elevators, or another + // stair instead of only lining up the invisible origin point. let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) - // Snap the stair origin onto another object's nearest real anchor and - // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped - // point: resolving against the grid point would only ever catch anchors - // that happen to sit on a grid line, so off-grid items (furniture, angled - // walls) would never surface a guide. The matched axis locks exactly to the - // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses. + const resolveStairFootprintAlignment = ( + x: number, + z: number, + rotation: number, + ): ReturnType | null => { + const preview = buildPreviewScene([x, 0, z], rotation) + const moving = preview + ? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation) + : [] + if (moving.length === 0) return null + return resolveAlignment({ + moving, + candidates: alignmentCandidates, + threshold: ALIGNMENT_THRESHOLD_M, + }) + } + // The probe is the RAW cursor, not the grid-snapped point: resolving + // against the grid point would only catch anchors that happen to sit near + // a grid line. Matched axes use the raw probe + snap delta; unmatched axes + // keep the normal grid snap. Alt bypasses. const alignPoint = ( gridX: number, gridZ: number, @@ -314,22 +331,19 @@ export const StairTool: React.FC = () => { useAlignmentGuides.getState().clear() return [gridX, gridZ] } - const ar = resolveAlignment({ - moving: [{ nodeId: '__stair-draft__', kind: 'corner', x: rawX, z: rawZ }], - candidates: alignmentCandidates, - threshold: ALIGNMENT_THRESHOLD_M, - }) - if (ar.guides.length === 0) { + const ar = resolveStairFootprintAlignment(rawX, rawZ, rotationRef.current) + if (!ar || ar.guides.length === 0) { useAlignmentGuides.getState().clear() return [gridX, gridZ] } - useAlignmentGuides.getState().set(ar.guides) let x = gridX let z = gridZ - for (const guide of ar.guides) { - if (guide.axis === 'x') x = guide.coord - else z = guide.coord + if (ar.snap) { + if (ar.guides.some((guide) => guide.axis === 'x')) x = rawX + ar.snap.dx + if (ar.guides.some((guide) => guide.axis === 'z')) z = rawZ + ar.snap.dz } + const finalAlignment = resolveStairFootprintAlignment(x, z, rotationRef.current) + useAlignmentGuides.getState().set(finalAlignment?.guides ?? ar.guides) return [x, z] } diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 43a71250..b020e309 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -61,6 +61,7 @@ export { export { CursorSphere } from './components/tools/shared/cursor-sphere' export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' +export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, @@ -195,6 +196,7 @@ export { type FloorplanStairSegmentEntry, getFloorplanWallThickness, } from './lib/floorplan' +export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { buildResetSurfaceMaterialUpdates, buildRoofSurfaceMaterialPatch, @@ -204,6 +206,17 @@ export { getActivePaintMaterialLabel, hasActivePaintMaterial, } from './lib/material-paint' +export { + addFreshPlacementMetadata, + getPlacementMetadataRecord, + isFreshPlacementMetadata, + stripPlacementMetadataFlags, +} from './lib/placement-metadata' +export { + type PlanarCursorPlacementMode, + type PlanarPoint, + resolvePlanarCursorPosition, +} from './lib/planar-cursor-placement' export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' diff --git a/packages/editor/src/lib/fresh-planar-placement.test.ts b/packages/editor/src/lib/fresh-planar-placement.test.ts new file mode 100644 index 00000000..7c67c4be --- /dev/null +++ b/packages/editor/src/lib/fresh-planar-placement.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { useScene } from '@pascal-app/core' +import { commitFreshPlacementSubtree } from './fresh-planar-placement' + +type RafFn = (cb: (time: number) => void) => number +;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (time: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {} + +const LEVEL_ID = 'level_test' as AnyNodeId +const SHELF_ID = 'shelf_draft' as AnyNodeId + +function level(children: AnyNodeId[]): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children, + level: 0, + } as AnyNode +} + +function shelf(): AnyNode { + return { + id: SHELF_ID, + type: 'shelf', + object: 'node', + parentId: LEVEL_ID, + visible: false, + metadata: { isNew: true, label: 'draft' }, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + width: 1.2, + depth: 0.3, + thickness: 0.04, + height: 0.9, + style: 'wall-shelf', + rows: 1, + columns: 1, + withBack: false, + withSides: true, + withBottom: false, + bracketStyle: 'minimal', + } as AnyNode +} + +describe('commitFreshPlacementSubtree', () => { + beforeEach(() => { + useScene.setState({ + nodes: { + [LEVEL_ID]: level([SHELF_ID]), + [SHELF_ID]: shelf(), + }, + rootNodeIds: [LEVEL_ID], + collections: {}, + dirtyNodes: new Set(), + } as never) + useScene.temporal.getState().clear() + useScene.temporal.getState().resume() + }) + + test('commits a fresh draft as one undoable clean subtree', () => { + useScene.temporal.getState().pause() + + const committedId = commitFreshPlacementSubtree(SHELF_ID, { + position: [2, 0, 3], + visible: true, + } as Partial) + + expect(committedId).toBeTruthy() + expect(committedId).not.toBe(SHELF_ID) + const finalId = committedId as AnyNodeId + expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined() + + const committed = useScene.getState().nodes[finalId] as + | (AnyNode & { position: [number, number, number]; metadata?: Record }) + | undefined + expect(committed?.position).toEqual([2, 0, 3]) + expect(committed?.visible).toBe(true) + expect(committed?.metadata?.isNew).toBeUndefined() + expect(committed?.metadata?.label).toBe('draft') + expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([ + finalId, + ]) + + useScene.temporal.getState().resume() + useScene.temporal.getState().undo() + + expect(useScene.getState().nodes[finalId]).toBeUndefined() + expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined() + expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([]) + }) +}) diff --git a/packages/editor/src/lib/fresh-planar-placement.ts b/packages/editor/src/lib/fresh-planar-placement.ts new file mode 100644 index 00000000..6bbf29a9 --- /dev/null +++ b/packages/editor/src/lib/fresh-planar-placement.ts @@ -0,0 +1,63 @@ +import { + type AnyNode, + type AnyNodeId, + cloneNodesInto, + collectSubtree, + useScene, +} from '@pascal-app/core' +import { stripPlacementMetadataFlags } from './placement-metadata' + +function cleanPlacementMetadata(node: N): N { + return { + ...node, + metadata: stripPlacementMetadataFlags(node.metadata), + } as N +} + +function parentIdOf(node: AnyNode): AnyNodeId | undefined { + const parentId = (node as { parentId?: AnyNodeId | null }).parentId + return parentId ?? undefined +} + +/** + * Finalises a fresh catalog/duplicate draft as a single undoable creation. + * + * Fresh drafts already exist in the scene so renderers and move tools can + * preview real geometry. On commit we delete that draft while history is + * paused, then create a clean clone at the final cursor position with history + * resumed. Undo therefore removes the placed node instead of resurrecting the + * hidden draft at its origin. + */ +export function commitFreshPlacementSubtree( + rootId: AnyNodeId, + rootPatch: Partial, +): AnyNodeId | null { + const scene = useScene.getState() + const subtree = collectSubtree(scene.nodes, rootId) + if (!subtree) return null + + const root = cleanPlacementMetadata({ + ...subtree.root, + ...rootPatch, + } as AnyNode) + const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node)) + const parentId = parentIdOf(root) + const cloned = cloneNodesInto([root, ...descendants], { + rootId, + parentId, + }) + + const temporal = useScene.temporal.getState() + const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false + if (wasTracking) temporal.pause() + useScene.getState().deleteNode(rootId) + temporal.resume() + useScene + .getState() + .createNodes( + cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })), + ) + if (!wasTracking) temporal.pause() + + return cloned.rootId +} diff --git a/packages/editor/src/lib/placement-metadata.ts b/packages/editor/src/lib/placement-metadata.ts new file mode 100644 index 00000000..2d15e634 --- /dev/null +++ b/packages/editor/src/lib/placement-metadata.ts @@ -0,0 +1,29 @@ +export function getPlacementMetadataRecord(metadata: unknown): Record { + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) { + return {} + } + + return metadata as Record +} + +export function addFreshPlacementMetadata(metadata: unknown): Record { + return { + ...getPlacementMetadataRecord(metadata), + isNew: true, + } +} + +export function isFreshPlacementMetadata(metadata: unknown): boolean { + return getPlacementMetadataRecord(metadata).isNew === true +} + +export function stripPlacementMetadataFlags(metadata: unknown): unknown { + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) { + return metadata + } + + const nextMeta = { ...(metadata as Record) } + delete nextMeta.isNew + delete nextMeta.isTransient + return nextMeta +} diff --git a/packages/editor/src/lib/planar-cursor-placement.test.ts b/packages/editor/src/lib/planar-cursor-placement.test.ts new file mode 100644 index 00000000..8308bb50 --- /dev/null +++ b/packages/editor/src/lib/planar-cursor-placement.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test' +import { resolvePlanarCursorPosition } from './planar-cursor-placement' + +const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5 + +describe('resolvePlanarCursorPosition', () => { + test('absolute mode places the point directly at the snapped cursor', () => { + const result = resolvePlanarCursorPosition({ + cursor: [1.24, -2.26], + original: [10, 10], + anchor: null, + mode: 'absolute', + snap: snapHalf, + }) + + expect(result.point).toEqual([1, -2.5]) + expect(result.anchor).toBeNull() + }) + + test('relative mode preserves the original grab offset from the first cursor sample', () => { + const start = resolvePlanarCursorPosition({ + cursor: [4.1, 6.1], + original: [10, 20], + anchor: null, + mode: 'relative', + snap: snapHalf, + }) + + expect(start.point).toEqual([10, 20]) + expect(start.anchor).toEqual([4.1, 6.1]) + + const moved = resolvePlanarCursorPosition({ + cursor: [4.9, 5.2], + original: [10, 20], + anchor: start.anchor, + mode: 'relative', + snap: snapHalf, + }) + + expect(moved.point).toEqual([11, 19]) + expect(moved.anchor).toEqual([4.1, 6.1]) + }) +}) diff --git a/packages/editor/src/lib/planar-cursor-placement.ts b/packages/editor/src/lib/planar-cursor-placement.ts new file mode 100644 index 00000000..a1ea5205 --- /dev/null +++ b/packages/editor/src/lib/planar-cursor-placement.ts @@ -0,0 +1,42 @@ +export type PlanarPoint = [number, number] + +export type PlanarCursorPlacementMode = 'absolute' | 'relative' + +type ResolvePlanarCursorPositionArgs = { + cursor: PlanarPoint + original: PlanarPoint + anchor: PlanarPoint | null + mode: PlanarCursorPlacementMode + snap?: (value: number) => number +} + +type ResolvePlanarCursorPositionResult = { + point: PlanarPoint + anchor: PlanarPoint | null +} + +const identity = (value: number) => value + +export function resolvePlanarCursorPosition({ + cursor, + original, + anchor, + mode, + snap = identity, +}: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult { + if (mode === 'absolute') { + return { + point: [snap(cursor[0]), snap(cursor[1])], + anchor, + } + } + + const resolvedAnchor = anchor ?? cursor + return { + point: [ + original[0] + snap(cursor[0] - resolvedAnchor[0]), + original[1] + snap(cursor[1] - resolvedAnchor[1]), + ], + anchor: resolvedAnchor, + } +} diff --git a/packages/editor/src/lib/roof-duplication.ts b/packages/editor/src/lib/roof-duplication.ts index cf6b1da5..d46998ce 100644 --- a/packages/editor/src/lib/roof-duplication.ts +++ b/packages/editor/src/lib/roof-duplication.ts @@ -175,7 +175,7 @@ export function duplicateRoofSubtree( export function clearRoofDuplicateMetadata( roofId: AnyNodeId, - updates: Partial> = {}, + updates: Partial> = {}, ) { const scene = useScene.getState() const roofNode = scene.nodes[roofId] diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index a6ff8861..0f8f3552 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -1,6 +1,6 @@ 'use client' -import { resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core' +import { nodeRegistry, resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import useEditor, { hasCustomPersistedEditorUiState, @@ -220,7 +220,11 @@ function getValidatedSelectionForScene( const selectedIds = selection.selectedIds.filter((id) => { const node = sceneNodes[id] - return Boolean(node) && resolveLevelId(node, sceneNodes) === levelId + if (!node) return false + if (resolveLevelId(node, sceneNodes) === levelId) return true + + const def = nodeRegistry.get(node.type) + return def?.floorplanScope === 'building' && node.parentId === buildingId }) return { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 91c00d56..a49ed91e 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -118,6 +118,18 @@ export type StructureLayer = 'zones' | 'elements' export type FloorplanSelectionTool = 'click' | 'marquee' export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05 +export type NavigationSyncSource = '2d' | '3d' + +export type NavigationSyncPose = { + source: NavigationSyncSource + revision: number + target: [number, number, number] + azimuth: number + viewWidth: number +} + +export type NavigationSyncPoseInput = Omit + // Combined tool type export type Tool = SiteTool | StructureTool | FurnishTool @@ -326,6 +338,8 @@ type EditorState = { toggleFloorplanOpen: () => void isFloorplanHovered: boolean setFloorplanHovered: (hovered: boolean) => void + navigationSyncPose: NavigationSyncPose | null + publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void floorplanSelectionTool: FloorplanSelectionTool setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void gridSnapStep: GridSnapStep @@ -850,6 +864,14 @@ const useEditor = create()( }), isFloorplanHovered: false, setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }), + navigationSyncPose: null, + publishNavigationSyncPose: (pose) => + set((state) => ({ + navigationSyncPose: { + ...pose, + revision: (state.navigationSyncPose?.revision ?? 0) + 1, + }, + })), floorplanSelectionTool: 'click' as FloorplanSelectionTool, setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, diff --git a/packages/nodes/src/column/floorplan-move.ts b/packages/nodes/src/column/floorplan-move.ts index fbf845fe..73572f84 100644 --- a/packages/nodes/src/column/floorplan-move.ts +++ b/packages/nodes/src/column/floorplan-move.ts @@ -10,10 +10,11 @@ import { } from '@pascal-app/core' import { applyFloorplanAlignment, - snapPointToGrid, triggerSFX, + useEditor, type WallPlanPoint, } from '@pascal-app/editor' +import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' /** * 2D floor-plan move handler for column — mirrors `itemFloorplanMoveTarget`: @@ -39,12 +40,14 @@ import { * Column stores rotation as a scalar (not a tuple); position is `[x, y, z]`. */ -const GRID_STEP = 0.5 - export const columnFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => { const columnId = node.id as AnyNodeId const originalPosition: [number, number, number] = [...node.position] as [number, number, number] const rotationY = node.rotation ?? 0 + const resolveCursor = createFloorplanCursorResolver({ + original: [originalPosition[0], originalPosition[2]], + metadata: node.metadata, + }) let lastPosition: [number, number, number] = originalPosition let lastSnapKey: string | null = null @@ -54,9 +57,12 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget = ({ nod const session: FloorplanMoveTargetSession = { affectedIds: [columnId], apply({ planPoint, modifiers }) { - const gridSnapped: WallPlanPoint = modifiers.shiftKey - ? ([planPoint[0], planPoint[1]] as WallPlanPoint) - : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + const snap = (value: number) => { + if (modifiers.shiftKey) return value + const step = useEditor.getState().gridSnapStep + return Math.round(value / step) * step + } + const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint // Figma-style alignment layered on the grid snap (Alt bypasses). const { point: snapped } = applyFloorplanAlignment( gridSnapped, diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index 25cea8de..21388a5a 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -16,12 +16,17 @@ import { } from '@pascal-app/core' import { CursorSphere, + commitFreshPlacementSubtree, DragBoundingBox, getFloorStackPreviewPosition, markToolCancelConsumed, + resolvePlanarCursorPosition, + stripPlacementMetadataFlags, triggerSFX, useEditor, + useFreshPlacementVisibility, } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' /** @@ -54,6 +59,8 @@ const ALIGNMENT_THRESHOLD_M = 0.08 function MoveColumnTool({ node }: { node: ColumnNode }) { const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) const [previewRotation, setPreviewRotation] = useState(node.rotation) + const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } = + useFreshPlacementVisibility({ node }) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -71,11 +78,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { // Latest previewed position, so an R/T press can re-apply at the spot. let lastPosition: [number, number, number] = node.position let dragAnchor: [number, number] | null = null - const meta = - typeof node.metadata === 'object' && node.metadata !== null - ? (node.metadata as Record) - : {} - const isNew = !!meta.isNew + const isNew = isFreshPlacement const getVisualPosition = ( position: [number, number, number], rotation = rotationY, @@ -114,9 +117,17 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { hasMoved = true const rawX = event.localPosition[0] const rawZ = event.localPosition[2] - dragAnchor ??= [rawX, rawZ] - let x = node.position[0] + snapToGridStep(rawX - dragAnchor[0]) - let z = node.position[2] + snapToGridStep(rawZ - dragAnchor[1]) + revealFreshPlacement() + + const resolved = resolvePlanarCursorPosition({ + cursor: [rawX, rawZ], + original: [node.position[0], node.position[2]], + anchor: dragAnchor, + mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative', + snap: snapToGridStep, + }) + dragAnchor = resolved.anchor + let [x, z] = resolved.point // Figma-style alignment snap on top of grid snap; Alt bypasses. The // guide connects to the candidate's nearest real anchor (resolver @@ -161,13 +172,30 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { // click to the grid. const position: [number, number, number] = [...lastPosition] const nodeId = (node as { id?: ColumnNode['id'] }).id + let committedId = node.id as AnyNodeId if (nodeId && useScene.getState().nodes[nodeId]) { - committed = true - useScene.temporal.getState().resume() - useScene - .getState() - .updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) }) + const data = { + position, + rotation: rotationY, + ...(isNew + ? { + metadata: stripPlacementMetadataFlags(node.metadata) as ColumnNode['metadata'], + visible: true, + } + : null), + } + if (isNew) { + const finalId = commitFreshPlacementSubtree(nodeId as AnyNodeId, data) + if (finalId) { + committed = true + committedId = finalId + } + } else { + committed = true + useScene.temporal.getState().resume() + useScene.getState().updateNode(nodeId, data) + } useLiveTransforms.getState().clear(nodeId) const m = sceneRegistry.nodes.get(nodeId) if (m) { @@ -188,7 +216,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { } useLiveTransforms.getState().clear(node.id) + if (isNew && committed) { + useViewer.getState().setSelection({ selectedIds: [committedId] }) + } triggerSFX('sfx:item-place') + useEditor.getState().setMovingNodeOrigin('3d') exitMoveMode() event.nativeEvent?.stopPropagation?.() } @@ -196,12 +228,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const onCancel = () => { useLiveTransforms.getState().clear(node.id) useAlignmentGuides.getState().clear() - const m = sceneRegistry.nodes.get(node.id) - if (m) { - m.position.set(...getVisualPosition(node.position, node.rotation)) - m.rotation.y = node.rotation + if (isNew) { + useScene.getState().deleteNode(node.id as AnyNodeId) + } else { + const m = sceneRegistry.nodes.get(node.id) + if (m) { + m.position.set(...getVisualPosition(node.position, node.rotation)) + m.rotation.y = node.rotation + } + useScene.getState().markDirty(node.id as AnyNodeId) } - useScene.getState().markDirty(node.id as AnyNodeId) useScene.temporal.getState().resume() markToolCancelConsumed() exitMoveMode() @@ -219,17 +255,20 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { emitter.off('tool:cancel', onCancel) useLiveTransforms.getState().clear(node.id) useAlignmentGuides.getState().clear() - if (!committed) { + const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d' + if (!(committed || isNew || finalisedBy2D)) { const m = sceneRegistry.nodes.get(node.id) if (m) { m.position.set(...getVisualPosition(node.position, node.rotation)) m.rotation.y = node.rotation } useScene.getState().markDirty(node.id as AnyNodeId) - useScene.temporal.getState().resume() } + useScene.temporal.getState().resume() } - }, [exitMoveMode, node]) + }, [exitMoveMode, isFreshPlacement, node, revealFreshPlacement, useAbsoluteCursorPlacement]) + + if (!previewVisible) return null return ( <> diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index fc2db9d1..fdbc0cff 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -7,24 +7,27 @@ import { collectAlignmentAnchors, emitter, type GridEvent, - movingFootprintAnchors, - resolveAlignment, - snapPointToGrid, useAlignmentGuides, useScene, } from '@pascal-app/core' -import { getFloorStackPreviewPosition, triggerSFX, usePlacementPreview } from '@pascal-app/editor' +import { + getFloorStackPreviewPosition, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { Group } from 'three' +import { + type FloorPlacementClickTriggerEvent, + getLevelLocalSnappedPosition, + resolveAlignedFloorPlacement, + stopPlacementCommitPropagation, + subscribeFloorPlacementClicks, +} from '../shared/floor-placement' import { ColumnPreview } from './renderer' -const GRID_STEP = 0.5 - -/** Figma-style alignment-snap threshold (meters), matching the move tools and - * the shelf placement tool. */ -const ALIGNMENT_THRESHOLD_M = 0.08 - const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) { @@ -52,6 +55,8 @@ const ColumnTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) const previousSnapRef = useRef<[number, number] | null>(null) + const cursorVisibleRef = useRef(false) + const [cursorVisible, setCursorVisible] = useState(false) // Default-preset column for the placement ghost — matches exactly what the // commit creates (`basicPillar`), so the preview is faithful. @@ -60,6 +65,9 @@ const ColumnTool = () => { useEffect(() => { if (!activeLevelId) return previousSnapRef.current = null + cursorVisibleRef.current = false + setCursorVisible(false) + const lastCursorRef: { current: [number, number, number] | null } = { current: null } // Alignment candidates — anchors of every other alignable object, gathered // here and refreshed after each placement so a just-placed column becomes a @@ -68,30 +76,21 @@ const ColumnTool = () => { let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) const onGridMove = (event: GridEvent) => { - const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) - - // Figma-style alignment snap layered on top of grid snap: when the - // preview column's footprint edge lines up (on X or Z) with another - // object's edge, snap there and publish a guide. Alt bypasses. - let ax = sx - let az = sz - const bypass = event.nativeEvent?.altKey === true - if (!bypass && alignmentCandidates.length > 0) { - const result = resolveAlignment({ - moving: movingFootprintAnchors(previewNode, sx, sz, 0), - candidates: alignmentCandidates, - threshold: ALIGNMENT_THRESHOLD_M, - }) - if (result.snap) { - ax += result.snap.dx - az += result.snap.dz - } - useAlignmentGuides.getState().set(result.guides) - } else { - useAlignmentGuides.getState().clear() + if (!cursorVisibleRef.current) { + cursorVisibleRef.current = true + setCursorVisible(true) } - const position: [number, number, number] = [ax, 0, az] + const { position, guides } = resolveAlignedFloorPlacement({ + node: previewNode, + rawX: event.localPosition[0], + rawZ: event.localPosition[2], + gridStep: useEditor.getState().gridSnapStep, + candidates: alignmentCandidates, + bypassAlignment: event.nativeEvent?.altKey === true, + }) + useAlignmentGuides.getState().set(guides) + const visualPosition = getFloorStackPreviewPosition({ node: previewNode, position, @@ -99,6 +98,7 @@ const ColumnTool = () => { levelId: activeLevelId, }) cursorRef.current?.position.set(...visualPosition) + lastCursorRef.current = position // Publish a transient, positioned preview node for the 2D floor-plan // ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan @@ -107,30 +107,18 @@ const ColumnTool = () => { usePlacementPreview.getState().set({ ...previewNode, position }) const prev = previousSnapRef.current - if (!prev || prev[0] !== ax || prev[1] !== az) { + if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { triggerSFX('sfx:grid-snap') - previousSnapRef.current = [ax, az] + previousSnapRef.current = [position[0], position[2]] } } - const onGridClick = (event: GridEvent) => { - const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) - let ax = sx - let az = sz - const bypass = event.nativeEvent?.altKey === true - if (!bypass && alignmentCandidates.length > 0) { - const result = resolveAlignment({ - moving: movingFootprintAnchors(previewNode, sx, sz, 0), - candidates: alignmentCandidates, - threshold: ALIGNMENT_THRESHOLD_M, - }) - if (result.snap) { - ax += result.snap.dx - az += result.snap.dz - } - } + const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => { + const position = + lastCursorRef.current ?? + getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep) - const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [ax, 0, az]) + const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) useScene.getState().createNode(column, activeLevelId) useViewer.getState().setSelection({ selectedIds: [column.id] }) triggerSFX('sfx:structure-build') @@ -140,14 +128,15 @@ const ColumnTool = () => { alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) useAlignmentGuides.getState().clear() usePlacementPreview.getState().clear() + stopPlacementCommitPropagation(event) } emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) + const unsubscribePlacementClicks = subscribeFloorPlacementClicks(commitAtCursor) return () => { emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) + unsubscribePlacementClicks() useAlignmentGuides.getState().clear() usePlacementPreview.getState().clear() } @@ -156,7 +145,7 @@ const ColumnTool = () => { if (!activeLevelId) return null return ( - + ) diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index ac7bc422..0aca81ca 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -4,9 +4,15 @@ import { type FloorplanMoveTarget, type FloorplanMoveTargetSession, useScene, + type WallNode, } from '@pascal-app/core' import { snapToHalf } from '@pascal-app/editor' -import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target' +import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' +import { + findClosestWallInPlan, + projectWallLocalPointToPlan, + snapLocalXToNeighbors, +} from '../shared/wall-attach-target' import { clampToWall, hasWallChildOverlap } from './door-math' /** @@ -36,6 +42,16 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) const wall = useScene.getState().nodes[node.parentId as AnyNodeId] return wall ? (wall.parentId as AnyNodeId | null) : null })() + const originalWall = node.parentId + ? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined) + : undefined + const resolveCursor = createFloorplanCursorResolver({ + original: + originalWall?.type === 'wall' + ? projectWallLocalPointToPlan(originalWall, node.position[0]) + : [node.position[0], 0], + metadata: node.metadata, + }) // Track the last successful placement so `commit()` can write it // atomically — see the comment on `commit` below for why we don't @@ -52,7 +68,8 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) affectedIds: [node.id as AnyNodeId], apply({ planPoint, modifiers }) { const nodes = useScene.getState().nodes - const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) + const resolvedPlanPoint = resolveCursor(planPoint) + const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) if (!hit) return // pointer off any wall — keep door at last valid position // Figma-style along-wall alignment first (edge-to-edge with other diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts index 16622e95..623cef13 100644 --- a/packages/nodes/src/elevator/definition.ts +++ b/packages/nodes/src/elevator/definition.ts @@ -17,6 +17,7 @@ import { ElevatorNode } from './schema' const SIDE_HANDLE_OFFSET = 0.22 const HEIGHT_HANDLE_OFFSET = 0.3 +const MOVE_FRONT_OFFSET = 0.35 const MIN_ELEVATOR_DIM = 0.6 const MIN_CAB_HEIGHT = 1.4 const ROTATE_CORNER_OFFSET = 0.4 @@ -81,6 +82,16 @@ function elevatorCabHeightHandle(): HandleDescriptor { } } +function elevatorOuterHalfExtents(n: ElevatorNodeType): { halfX: number; halfZ: number } { + const cabWidth = getElevatorCabWidth(n) + const cabDepth = getElevatorCabDepth(n) + const wallThickness = getElevatorShaftWallThickness(n) + return { + halfX: getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness, + halfZ: getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness, + } +} + // Rotation handle — sits at the front-right corner of the shaft // footprint. `arc-resize` does the angular drag math (raycasts a // horizontal plane at the arrow's Y, measures cursor angle around the @@ -103,11 +114,7 @@ function elevatorRotateHandle(): HandleDescriptor { // shaft rather than diagonally at the corner — matches the column's // one-direction rotate placement. position: (n) => { - const cabWidth = getElevatorCabWidth(n) - const cabDepth = getElevatorCabDepth(n) - const wallThickness = getElevatorShaftWallThickness(n) - const halfX = getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness - const halfZ = getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness + const { halfX, halfZ } = elevatorOuterHalfExtents(n) const yMid = Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2 return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET] }, @@ -120,11 +127,7 @@ function elevatorRotateHandle(): HandleDescriptor { // Bounding circle through the shaft corners — drawn slightly larger // so it sits outside the visible shell. radius: (n) => { - const cabWidth = getElevatorCabWidth(n) - const cabDepth = getElevatorCabDepth(n) - const wallThickness = getElevatorShaftWallThickness(n) - const halfX = getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness - const halfZ = getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness + const { halfX, halfZ } = elevatorOuterHalfExtents(n) return Math.hypot(halfX, halfZ) + ROTATE_RING_OFFSET }, y: (n) => Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2, @@ -132,11 +135,32 @@ function elevatorRotateHandle(): HandleDescriptor { } } +function elevatorMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + position: (n) => { + const { halfZ } = elevatorOuterHalfExtents(n) + return [0, 0.02, halfZ + MOVE_FRONT_OFFSET] + }, + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: (n) => { + const { halfX, halfZ } = elevatorOuterHalfExtents(n) + const dimX = Math.max(halfX * 2, MIN_ELEVATOR_DIM) + const dimZ = Math.max(halfZ * 2, MIN_ELEVATOR_DIM) + const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9 + return [swap ? dimZ : dimX, swap ? dimX : dimZ] + }, + } +} + const elevatorHandles: HandleDescriptor[] = [ elevatorAxisHandle('x'), elevatorAxisHandle('z'), elevatorCabHeightHandle(), elevatorRotateHandle(), + elevatorMoveHandle(), ] /** @@ -174,10 +198,10 @@ export const elevatorDefinition: NodeDefinition = { // bridge relocates this same footprint to the drag point. alignmentFootprint: (node) => { const e = node as ElevatorNodeType - const wall = getElevatorShaftWallThickness(e) + const { halfX, halfZ } = elevatorOuterHalfExtents(e) return { shape: 'box', - dimensions: [getElevatorShaftWidth(e) + wall * 2, 1, getElevatorShaftDepth(e) + wall * 2], + dimensions: [halfX * 2, 1, halfZ * 2], rotation: [0, e.rotation ?? 0, 0], } }, diff --git a/packages/nodes/src/item/floorplan-move.ts b/packages/nodes/src/item/floorplan-move.ts index 039509b0..a8c1b747 100644 --- a/packages/nodes/src/item/floorplan-move.ts +++ b/packages/nodes/src/item/floorplan-move.ts @@ -10,7 +10,8 @@ import { movingFootprintAnchors, useScene, } from '@pascal-app/core' -import { applyFloorplanAlignment, snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' +import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor' +import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target' /** @@ -34,7 +35,95 @@ import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-att * the item's current attach family. */ -const GRID_STEP = 0.5 +type ItemPlanTransform = { + point: [number, number] + rotation: number +} + +function rotateVec(x: number, z: number, rotationY: number): [number, number] { + const c = Math.cos(rotationY) + const s = Math.sin(rotationY) + return [x * c + z * s, -x * s + z * c] +} + +function resolveItemPlanTransform( + item: ItemNode, + nodes: Record, + cache = new Map(), +): ItemPlanTransform { + const cached = cache.get(item.id as AnyNodeId) + if (cached) return cached + + const localRotation = item.rotation[1] ?? 0 + let result: ItemPlanTransform = { + point: [item.position[0], item.position[2]], + rotation: localRotation, + } + const parent = item.parentId ? nodes[item.parentId as AnyNodeId] : null + if (parent?.type === 'wall') { + const wallRotation = -Math.atan2( + parent.end[1] - parent.start[1], + parent.end[0] - parent.start[0], + ) + const wallLocalZ = + item.asset.attachTo === 'wall-side' + ? ((parent.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1) + : item.position[2] + const [offsetX, offsetZ] = rotateVec(item.position[0], wallLocalZ, wallRotation) + result = { + point: [parent.start[0] + offsetX, parent.start[1] + offsetZ], + rotation: wallRotation + localRotation, + } + } else if (parent?.type === 'shelf') { + const shelf = parent as AnyNode & { + position: [number, number, number] + rotation: [number, number, number] + } + const [offsetX, offsetZ] = rotateVec(item.position[0], item.position[2], shelf.rotation[1] ?? 0) + result = { + point: [shelf.position[0] + offsetX, shelf.position[2] + offsetZ], + rotation: (shelf.rotation[1] ?? 0) + localRotation, + } + } else if (parent?.type === 'item') { + const parentTransform = resolveItemPlanTransform(parent as ItemNode, nodes, cache) + const [offsetX, offsetZ] = rotateVec( + item.position[0], + item.position[2], + parentTransform.rotation, + ) + result = { + point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ], + rotation: parentTransform.rotation + localRotation, + } + } + + cache.set(item.id as AnyNodeId, result) + return result +} + +function resolveItemPlanPoint( + item: ItemNode, + nodes: Record, + cache = new Map(), +): [number, number] { + return resolveItemPlanTransform(item, nodes, cache).point +} + +function createPlanarMovePointResolver(originalPlanPoint: [number, number], node: ItemNode) { + const resolveCursor = createFloorplanCursorResolver({ + original: originalPlanPoint, + metadata: node.metadata, + }) + + return (planPoint: readonly [number, number], shiftKey: boolean): WallPlanPoint => { + const snap = (value: number) => { + if (shiftKey) return value + const step = useEditor.getState().gridSnapStep + return Math.round(value / step) * step + } + return resolveCursor(planPoint, { snap }) as WallPlanPoint + } +} export const itemFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => { const attachTo = node.asset.attachTo @@ -77,12 +166,17 @@ function buildWallItemSession( // local-Y carries over from the source item's position (2D can't // express vertical movement). const startLocalY = node.position[1] + const resolveCursor = createFloorplanCursorResolver({ + original: resolveItemPlanPoint(node, useScene.getState().nodes), + metadata: node.metadata, + }) return { affectedIds: [node.id as AnyNodeId], apply({ planPoint, modifiers }) { const nodes = useScene.getState().nodes - const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) + const resolvedPlanPoint = resolveCursor(planPoint) + const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) if (!hit) return const [width] = getScaledDimensions(node) @@ -99,9 +193,9 @@ function buildWallItemSession( selfId: node.id as AnyNodeId, nodes, }) + const step = useEditor.getState().gridSnapStep const snappedLocalX = - neighborX ?? - (modifiers.shiftKey ? hit.localX : Math.round(hit.localX / GRID_STEP) * GRID_STEP) + neighborX ?? (modifiers.shiftKey ? hit.localX : Math.round(hit.localX / step) * step) const halfW = width / 2 const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX)) @@ -143,14 +237,13 @@ function buildFloorItemSession( nodes: Record, ): FloorplanMoveTargetSession { const rotationY = node.rotation[1] ?? 0 + const resolvePlanPoint = createPlanarMovePointResolver(resolveItemPlanPoint(node, nodes), node) // Alignment candidates gathered once — scene is stable during the drag. const candidates = collectAlignmentAnchors(nodes, node.id) return { affectedIds: [node.id as AnyNodeId], apply({ planPoint, modifiers }) { - const gridSnapped: WallPlanPoint = modifiers.shiftKey - ? ([planPoint[0], planPoint[1]] as WallPlanPoint) - : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + const gridSnapped = resolvePlanPoint(planPoint, modifiers.shiftKey) // Figma-style alignment layered on the grid snap (Alt bypasses). const { point: snapped } = applyFloorplanAlignment( gridSnapped, @@ -200,13 +293,15 @@ function buildSurfaceItemSession( startLevelId: AnyNodeId | null, targetKind: 'ceiling', ): FloorplanMoveTargetSession { + const resolvePlanPoint = createPlanarMovePointResolver( + resolveItemPlanPoint(node, useScene.getState().nodes), + node, + ) return { affectedIds: [node.id as AnyNodeId], apply({ planPoint, modifiers }) { const nodes = useScene.getState().nodes - const snapped: WallPlanPoint = modifiers.shiftKey - ? ([planPoint[0], planPoint[1]] as WallPlanPoint) - : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + const snapped = resolvePlanPoint(planPoint, modifiers.shiftKey) const surface = findContainingSurface(snapped, nodes, startLevelId, targetKind) diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.ts b/packages/nodes/src/roof-segment/floorplan-affordances.ts index 17e6b279..aa50683a 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -7,6 +7,8 @@ import { snapScalar, useScene, } from '@pascal-app/core' +import { getSegmentGridStep } from '@pascal-app/editor' +import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' const MIN_ROOF_DIM = 1 @@ -148,11 +150,15 @@ export const roofSegmentRotateAffordance: FloorplanAffordance = export const roofSegmentMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => { const segmentId = node.id as AnyNodeId const initialY = node.position[1] - const { roofRot, cosRoof, sinRoof } = resolveSegmentFrame(node, nodes) + const { cx, cz, roofRot, cosRoof, sinRoof } = resolveSegmentFrame(node, nodes) const roofId = (node as unknown as { parentId?: AnyNodeId | null }).parentId const roof = roofId ? (nodes[roofId] as RoofNode | undefined) : undefined const roofPosX = roof?.position[0] ?? 0 const roofPosZ = roof?.position[2] ?? 0 + const resolveCursor = createFloorplanCursorResolver({ + original: [cx, cz], + metadata: node.metadata, + }) // Inverse of the forward transform `[cosRoof, -sinRoof; sinRoof, cosRoof]` // is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor // back into roof-local coords. @@ -162,17 +168,13 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget = ({ no return { affectedIds: [segmentId], apply({ planPoint, modifiers }) { - const dx = planPoint[0] - roofPosX - const dz = planPoint[1] - roofPosZ + const step = getSegmentGridStep() + const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step)) + const worldPoint = resolveCursor(planPoint, { snap }) + const dx = worldPoint[0] - roofPosX + const dz = worldPoint[1] - roofPosZ let localX = dx * cosRoof + dz * sinRoof let localZ = -dx * sinRoof + dz * cosRoof - // 0.5m grid snap (alt held disables). Mirrors the generic Path 2 - // fallback's `snapPointToGrid` step so floor-plan moves feel - // consistent across kinds. - if (!modifiers.altKey) { - localX = Math.round(localX * 2) / 2 - localZ = Math.round(localZ * 2) / 2 - } lastLocal = [localX, initialY, localZ] useScene.getState().updateNode(segmentId, { position: lastLocal }) }, diff --git a/packages/nodes/src/shared/floor-placement.test.ts b/packages/nodes/src/shared/floor-placement.test.ts new file mode 100644 index 00000000..6cffafbd --- /dev/null +++ b/packages/nodes/src/shared/floor-placement.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import { type GridEvent, type NodeEvent, ShelfNode } from '@pascal-app/core' +import { Object3D } from 'three' +import { getLevelLocalSnappedPosition, resolveAlignedFloorPlacement } from './floor-placement' + +const nativeEvent = {} as GridEvent['nativeEvent'] + +describe('floor placement helpers', () => { + test('resolveAlignedFloorPlacement snaps to the provided grid step', () => { + const node = ShelfNode.parse({ position: [0, 0, 0] }) + + const { guides, position } = resolveAlignedFloorPlacement({ + node, + rawX: 0.13, + rawZ: 0.37, + gridStep: 0.25, + candidates: [], + }) + + expect(position).toEqual([0.25, 0, 0.25]) + expect(guides).toEqual([]) + }) + + test('getLevelLocalSnappedPosition falls back to node world position for node events', () => { + const node = ShelfNode.parse({ position: [0, 0, 0] }) + const event: NodeEvent = { + node, + position: [0.13, 0, 0.37], + localPosition: [42, 0, 42], + object: new Object3D(), + stopPropagation: () => {}, + nativeEvent, + } + + expect(getLevelLocalSnappedPosition('missing-level', event, 0.25)).toEqual([0.25, 0, 0.25]) + }) +}) diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts new file mode 100644 index 00000000..fa672f7b --- /dev/null +++ b/packages/nodes/src/shared/floor-placement.ts @@ -0,0 +1,125 @@ +import { + type AnyNode, + type EventSuffix, + emitter, + type GridEvent, + movingFootprintAnchors, + type NodeEvent, + resolveAlignment, + sceneRegistry, + snapPointToGrid, +} from '@pascal-app/core' +import { Vector3 } from 'three' + +export const FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M = 0.08 + +export const FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS = [ + 'shelf', + 'item', + 'slab', + 'ceiling', + 'wall', + 'fence', + 'column', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', +] as const + +export type FloorPlacementClickTriggerEvent = GridEvent | NodeEvent + +type FloorPlacementAlignmentArgs = { + node: AnyNode + rawX: number + rawZ: number + gridStep: number + candidates: Parameters[0]['candidates'] + bypassAlignment?: boolean + rotationY?: number +} + +const worldVector = new Vector3() + +export function getLevelLocalSnappedPosition( + levelId: string, + event: FloorPlacementClickTriggerEvent, + gridStep: number, +): [number, number, number] { + const levelObject = sceneRegistry.nodes.get(levelId) + if (!levelObject) { + const rawPoint = 'node' in event ? event.position : event.localPosition + const [sx, sz] = snapPointToGrid([rawPoint[0], rawPoint[2]], gridStep) + return [sx, 0, sz] + } + + worldVector.set(event.position[0], event.position[1], event.position[2]) + levelObject.updateWorldMatrix(true, false) + levelObject.worldToLocal(worldVector) + const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], gridStep) + return [sx, 0, sz] +} + +export function resolveAlignedFloorPlacement({ + node, + rawX, + rawZ, + gridStep, + candidates, + bypassAlignment = false, + rotationY = 0, +}: FloorPlacementAlignmentArgs) { + const [sx, sz] = snapPointToGrid([rawX, rawZ], gridStep) + let ax = sx + let az = sz + + const result = + !bypassAlignment && candidates.length > 0 + ? resolveAlignment({ + moving: movingFootprintAnchors(node, sx, sz, rotationY), + candidates, + threshold: FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M, + }) + : null + + if (result?.snap) { + ax += result.snap.dx + az += result.snap.dz + } + + return { + position: [ax, 0, az] as [number, number, number], + guides: result?.guides ?? [], + } +} + +export function stopPlacementCommitPropagation(event: FloorPlacementClickTriggerEvent) { + const native = (event as { nativeEvent?: unknown }).nativeEvent + const nativeStopPropagation = (native as { stopPropagation?: () => void } | undefined) + ?.stopPropagation + if (typeof nativeStopPropagation === 'function') { + nativeStopPropagation.call(native) + } + const direct = (event as { stopPropagation?: () => void }).stopPropagation + if (typeof direct === 'function') direct.call(event) +} + +export function subscribeFloorPlacementClicks( + onClick: (event: FloorPlacementClickTriggerEvent) => void, +) { + emitter.on('grid:click', onClick) + type SuffixedKey = `${K}:${EventSuffix}` + type ClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]> + for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { + const key = `${kind}:click` as ClickKey + emitter.on(key, onClick as never) + } + + return () => { + emitter.off('grid:click', onClick) + for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { + const key = `${kind}:click` as ClickKey + emitter.off(key, onClick as never) + } + } +} diff --git a/packages/nodes/src/shared/floorplan-cursor.test.ts b/packages/nodes/src/shared/floorplan-cursor.test.ts new file mode 100644 index 00000000..b3436909 --- /dev/null +++ b/packages/nodes/src/shared/floorplan-cursor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { createFloorplanCursorResolver } from './floorplan-cursor' + +describe('createFloorplanCursorResolver', () => { + test('keeps existing nodes at their original position on the first cursor sample', () => { + const resolveCursor = createFloorplanCursorResolver({ original: [4, 6] }) + + expect(resolveCursor([10, 12])).toEqual([4, 6]) + expect(resolveCursor([11, 14])).toEqual([5, 8]) + }) + + test('places fresh nodes absolutely under the cursor', () => { + const resolveCursor = createFloorplanCursorResolver({ + original: [0, 0], + metadata: { isNew: true }, + }) + + expect(resolveCursor([10, 12])).toEqual([10, 12]) + expect(resolveCursor([11, 14])).toEqual([11, 14]) + }) + + test('snaps relative movement without snapping the original position', () => { + const resolveCursor = createFloorplanCursorResolver({ original: [4.1, 6.1] }) + const snap = (value: number) => Math.round(value / 0.5) * 0.5 + + expect(resolveCursor([10.1, 12.1], { snap })).toEqual([4.1, 6.1]) + expect(resolveCursor([10.37, 12.88], { snap })).toEqual([4.6, 7.1]) + }) +}) diff --git a/packages/nodes/src/shared/floorplan-cursor.ts b/packages/nodes/src/shared/floorplan-cursor.ts new file mode 100644 index 00000000..2e7cc805 --- /dev/null +++ b/packages/nodes/src/shared/floorplan-cursor.ts @@ -0,0 +1,35 @@ +import { + isFreshPlacementMetadata, + type PlanarCursorPlacementMode, + type PlanarPoint, + resolvePlanarCursorPosition, +} from '@pascal-app/editor' + +type FloorplanCursorResolverOptions = { + snap?: (value: number) => number +} + +export function createFloorplanCursorResolver(args: { + original: readonly [number, number] + metadata?: unknown + mode?: PlanarCursorPlacementMode +}) { + const original: PlanarPoint = [args.original[0], args.original[1]] + const mode = args.mode ?? (isFreshPlacementMetadata(args.metadata) ? 'absolute' : 'relative') + let anchor: PlanarPoint | null = null + + return ( + planPoint: readonly [number, number], + options: FloorplanCursorResolverOptions = {}, + ): PlanarPoint => { + const resolved = resolvePlanarCursorPosition({ + cursor: [planPoint[0], planPoint[1]], + original, + anchor, + mode, + ...(options.snap ? { snap: options.snap } : {}), + }) + anchor = resolved.anchor + return resolved.point + } +} diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 0352934d..5826bb8d 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -5,6 +5,7 @@ import { type FenceNode, type GridEvent, type LevelNode, + movingAlignmentAnchors, nodeRegistry, type RoofNode, type RoofSegmentNode, @@ -19,11 +20,14 @@ import { } from '@pascal-app/core' import { CursorSphere, - clearRoofDuplicateMetadata, + commitFreshPlacementSubtree, getFloorStackPreviewPosition, + resolvePlanarCursorPosition, snapFenceDraftPoint, + stripPlacementMetadataFlags, triggerSFX, useEditor, + useFreshPlacementVisibility, type WallPlanPoint, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' @@ -36,6 +40,15 @@ const ALIGNMENT_THRESHOLD_M = 0.08 export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode }> = ({ node: movingNode }) => { + const { + isFreshPlacement, + previewVisible: cursorVisible, + revealFreshPlacement, + useAbsoluteCursorPlacement, + } = useFreshPlacementVisibility({ + node: movingNode, + enabled: movingNode.type === 'roof' || movingNode.type === 'stair', + }) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) @@ -82,25 +95,8 @@ export const MoveRoofTool: React.FC<{ dragAnchorRef.current = null previousGridPosRef.current = null - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - const committedMeta: RoofNode['metadata'] = (() => { - if ( - typeof movingNode.metadata !== 'object' || - movingNode.metadata === null || - Array.isArray(movingNode.metadata) - ) { - return movingNode.metadata - } - - const nextMeta = { ...movingNode.metadata } as Record - delete nextMeta.isNew - delete nextMeta.isTransient - return nextMeta as RoofNode['metadata'] - })() + const isNew = isFreshPlacement + const committedMeta = stripPlacementMetadataFlags(movingNode.metadata) as RoofNode['metadata'] const original = { position: [...movingNode.position] as [number, number, number], @@ -115,6 +111,7 @@ export const MoveRoofTool: React.FC<{ // expensive merged-mesh CSG rebuilds on every frame. let wasCommitted = false let wasCancelled = false + let hasMoved = false // Track pending rotation — no store updates during drag let pendingRotation: number = movingNode.rotation as number @@ -190,20 +187,28 @@ export const MoveRoofTool: React.FC<{ // Alignment for top-level stair / roof only. Segments live in parent-local // space (a different frame from the building-local candidate pool / guide - // layer), so we leave them on the plain grid+corner snap. The moving node - // is aligned by its ORIGIN point (how this tool positions it), snapped to - // any other alignable object's anchors. + // layer), so we leave them on the plain grid+corner snap. Stairs align by + // their footprint edges; roofs keep the origin-point behavior. const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof' const alignmentCandidates = alignTopLevel - ? collectAlignmentAnchors(useScene.getState().nodes, movingNode.id) + ? collectAlignmentAnchors( + useScene.getState().nodes, + movingNode.id, + movingNode.type === 'stair' ? levelId : undefined, + ) : [] const alignLocalPoint = (lx: number, lz: number, bypass: boolean): [number, number] => { if (!alignTopLevel || bypass || alignmentCandidates.length === 0) { useAlignmentGuides.getState().clear() return [lx, lz] } + const moving = + movingNode.type === 'stair' + ? movingAlignmentAnchors(movingNode, useScene.getState().nodes, lx, lz, pendingRotation) + : [] const ar = resolveAlignment({ - moving: [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }], + moving: + moving.length > 0 ? moving : [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }], candidates: alignmentCandidates, threshold: ALIGNMENT_THRESHOLD_M, }) @@ -277,6 +282,9 @@ export const MoveRoofTool: React.FC<{ } const onGridMove = (event: GridEvent) => { + hasMoved = true + revealFreshPlacement() + const y = event.position[1] const snappedLocal = snapFenceDraftPoint({ @@ -292,11 +300,14 @@ export const MoveRoofTool: React.FC<{ snappedLocal[0], snappedLocal[1], ) - const anchor = dragAnchorRef.current ?? [rawLocalX, rawLocalZ] - dragAnchorRef.current = anchor - - let localX = movingNode.position[0] + (rawLocalX - anchor[0]) - let localZ = movingNode.position[2] + (rawLocalZ - anchor[1]) + const resolved = resolvePlanarCursorPosition({ + cursor: [rawLocalX, rawLocalZ], + original: [movingNode.position[0], movingNode.position[2]], + anchor: dragAnchorRef.current, + mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative', + }) + dragAnchorRef.current = resolved.anchor + let [localX, localZ] = resolved.point if (alignTopLevel) { const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true) @@ -340,34 +351,37 @@ export const MoveRoofTool: React.FC<{ } const onGridClick = (event: GridEvent) => { + if (!hasMoved) return const [localX, , localZ] = lastLocalPosition useAlignmentGuides.getState().clear() wasCommitted = true - // The store still holds the original values (we didn't update during drag). - // Resume temporal and apply the final state as a single undoable step. - useScene.temporal.getState().resume() - - if (isNew && movingNode.type === 'roof') { - clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, { - position: [localX, movingNode.position[1], localZ], - rotation: pendingRotation, - metadata: committedMeta, - }) + let committedId = movingNode.id as AnyNodeId + if (isNew) { + committedId = + commitFreshPlacementSubtree(movingNode.id as AnyNodeId, { + position: [localX, movingNode.position[1], localZ], + rotation: pendingRotation, + metadata: committedMeta, + visible: true, + }) ?? committedId } else { + // The store still holds the original values (we didn't update during drag). + // Resume temporal and apply the final state as a single undoable step. + useScene.temporal.getState().resume() useScene.getState().updateNode(movingNode.id, { position: [localX, movingNode.position[1], localZ], rotation: pendingRotation, metadata: committedMeta, }) + useScene.temporal.getState().pause() } - useScene.temporal.getState().pause() - triggerSFX('sfx:item-place') - useViewer.getState().setSelection({ selectedIds: [movingNode.id] }) + useViewer.getState().setSelection({ selectedIds: [committedId] }) useLiveTransforms.getState().clear(movingNode.id) + useEditor.getState().setMovingNodeOrigin('3d') exitMoveMode() event.nativeEvent?.stopPropagation?.() } @@ -463,10 +477,10 @@ export const MoveRoofTool: React.FC<{ emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) } - }, [movingNode, exitMoveMode]) + }, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement]) return ( - + ) diff --git a/packages/nodes/src/shared/polygon-centroid-move.ts b/packages/nodes/src/shared/polygon-centroid-move.ts index 1155a1ce..516b076e 100644 --- a/packages/nodes/src/shared/polygon-centroid-move.ts +++ b/packages/nodes/src/shared/polygon-centroid-move.ts @@ -10,17 +10,16 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' +import { getSegmentGridStep, type WallPlanPoint } from '@pascal-app/editor' import type * as THREE from 'three' +import { createFloorplanCursorResolver } from './floorplan-cursor' /** * Shared 2D floor-plan move for polygon-based kinds (slab / ceiling / zone). * - * **Pivot semantics.** The move uses the polygon's **centroid** as the pivot: - * the centroid snaps to the (grid-snapped, then Figma-aligned) cursor — the - * same way a regular item's origin snaps to the cursor in both 3D and 2D. - * This replaces the old grab-relative delta ("drag from wherever you first - * touched"), so polygon kinds move consistently with every other item. + * Existing polygon kinds preserve the cursor grab offset; fresh catalog + * placement uses the polygon centroid as the cursor-following pivot. This + * matches the generic 3D move tool while keeping polygon geometry in vertices. * * **Why a delta in `useLiveTransforms`** (see `wiki/architecture/tools.md`): * polygon kinds carry their position in their vertices, not a `position` @@ -35,8 +34,6 @@ import type * as THREE from 'three' * ceiling: `height − 0.01`) so the 3D mesh doesn't teleport vertically in a * split view during the drag. */ -const GRID_STEP = 0.5 - /** Figma-style alignment threshold (meters) — parity with the 3D move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 @@ -66,6 +63,7 @@ export function createPolygonCentroidMoveTarget(args: { type: string polygon: Array<[number, number]> holes?: Array> + metadata?: unknown } nodes: Record /** 3D mesh Y the kind's system parks the group at on rebuild. */ @@ -80,6 +78,10 @@ export function createPolygonCentroidMoveTarget(args: { hole.map(([x, z]) => [x, z] as [number, number]), ) const originalCenter = polygonCentroid(originalPolygon) + const resolveCursor = createFloorplanCursorResolver({ + original: originalCenter, + metadata: node.metadata, + }) // Alignment candidates gathered once — the scene is stable during the drag. const candidates = collectAlignmentAnchors(nodes, id) let lastDelta: [number, number] = [0, 0] @@ -90,9 +92,9 @@ export function createPolygonCentroidMoveTarget(args: { // Centroid → snapped cursor. Grid-snap the target centroid (Shift // drops the grid snap), then layer Figma alignment on the translated // polygon's vertices and fold its snap into the delta. Alt bypasses. - const target: WallPlanPoint = modifiers.shiftKey - ? ([planPoint[0], planPoint[1]] as WallPlanPoint) - : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + const step = getSegmentGridStep() + const snap = (value: number) => (modifiers.shiftKey ? value : Math.round(value / step) * step) + const target = resolveCursor(planPoint, { snap }) as WallPlanPoint let dx = target[0] - originalCenter[0] let dz = target[1] - originalCenter[1] diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index 1dcab28c..f2bc6be2 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -49,6 +49,17 @@ export type WallHit = { itemRotation: number } +export function projectWallLocalPointToPlan( + wall: WallNode, + localX: number, + localZ = 0, +): [number, number] { + const angle = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const c = Math.cos(angle) + const s = Math.sin(angle) + return [wall.start[0] + localX * c + localZ * s, wall.start[1] - localX * s + localZ * c] +} + /** * Walk every wall under `parentLevelId` and return the closest one to * `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`. diff --git a/packages/nodes/src/shelf/floorplan-move.ts b/packages/nodes/src/shelf/floorplan-move.ts index 658ecd34..9b5dbe75 100644 --- a/packages/nodes/src/shelf/floorplan-move.ts +++ b/packages/nodes/src/shelf/floorplan-move.ts @@ -11,10 +11,11 @@ import { import { applyFloorplanAlignment, getFloorStackPreviewPosition, - snapPointToGrid, triggerSFX, + useEditor, type WallPlanPoint, } from '@pascal-app/editor' +import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' /** * 2D floor-plan move handler for shelf — mirrors `itemFloorplanMoveTarget`, @@ -38,12 +39,14 @@ import { * live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the * scene directly removes that second source of truth entirely. */ -const GRID_STEP = 0.5 - export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => { const shelfId = node.id as AnyNodeId const originalPosition: [number, number, number] = [...node.position] as [number, number, number] const originalRotationY = node.rotation[1] ?? 0 + const resolveCursor = createFloorplanCursorResolver({ + original: [originalPosition[0], originalPosition[2]], + metadata: node.metadata, + }) let lastPosition: [number, number, number] = originalPosition let lastSnapKey: string | null = null @@ -55,9 +58,12 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, const session: FloorplanMoveTargetSession = { affectedIds: [shelfId], apply({ planPoint, modifiers }) { - const gridSnapped: WallPlanPoint = modifiers.shiftKey - ? ([planPoint[0], planPoint[1]] as WallPlanPoint) - : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + const snap = (value: number) => { + if (modifiers.shiftKey) return value + const step = useEditor.getState().gridSnapStep + return Math.round(value / step) * step + } + const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint // Figma-style alignment layered on the grid snap — the shelf footprint // edges snap to neighbours / wall faces and a guide is published. Alt // bypasses (matches placement tools' "No snap"). diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index 997e8276..0db52341 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -1,91 +1,33 @@ 'use client' import { - type AnyNode, collectAlignmentAnchors, - type EventSuffix, emitter, type GridEvent, - movingFootprintAnchors, - type NodeEvent, - resolveAlignment, ShelfNode, - sceneRegistry, - snapPointToGrid, useAlignmentGuides, useScene, } from '@pascal-app/core' -import { getFloorStackPreviewPosition, triggerSFX } from '@pascal-app/editor' +import { getFloorStackPreviewPosition, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' -import { type Group, Vector3 } from 'three' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { Group } from 'three' +import { + type FloorPlacementClickTriggerEvent, + getLevelLocalSnappedPosition, + resolveAlignedFloorPlacement, + stopPlacementCommitPropagation, + subscribeFloorPlacementClicks, +} from '../shared/floor-placement' import { shelfDefinition } from './definition' import ShelfPreview from './preview' -const worldVector = new Vector3() -const GRID_STEP = 0.5 - -/** Figma-style alignment-snap threshold (meters), matching the move tools and - * the 2D floor-plan overlay. 8 cm gives a magnetic pull layered on top of the - * grid snap without fighting it. */ -const ALIGNMENT_THRESHOLD_M = 0.08 - -/** - * Click-trigger kinds: when the user clicks ANY of these during shelf - * placement, we commit at the latest cursor position. R3F's pointer - * raycaster dispatches to the closest intersected mesh, so a click on - * a wall / slab / item / etc. would otherwise never reach `grid:click` - * — the placement would silently drop. Listening for each kind's click - * (and committing at the snapshot of the last `grid:move` cursor) - * mirrors the fix in `MoveRegistryNodeTool`. - */ -const CLICK_TRIGGER_KINDS = [ - 'shelf', - 'item', - 'slab', - 'ceiling', - 'wall', - 'fence', - 'column', - 'roof', - 'roof-segment', - 'stair', - 'stair-segment', -] as const - -type ClickTriggerEvent = GridEvent | NodeEvent - -/** - * Convert the latest cursor world hit into level-local coords for the - * commit `position`. The cursor's local position from `event.localPosition` - * (building-local) needs to come back through the level's world transform - * so the shelf is stored in its parent's frame. - */ -function getLevelLocalPosition( - levelId: string, - event: GridEvent | NodeEvent, -): [number, number, number] { - const levelObject = sceneRegistry.nodes.get(levelId) - if (!levelObject) { - const local = (event as GridEvent).localPosition - if (local) { - const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP) - return [sx, 0, sz] - } - const [sx, sz] = snapPointToGrid([event.position[0], event.position[2]], GRID_STEP) - return [sx, 0, sz] - } - worldVector.set(event.position[0], event.position[1], event.position[2]) - levelObject.updateWorldMatrix(true, false) - levelObject.worldToLocal(worldVector) - const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP) - return [sx, 0, sz] -} - const ShelfTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) const previousSnapRef = useRef<[number, number] | null>(null) + const cursorVisibleRef = useRef(false) + const [cursorVisible, setCursorVisible] = useState(false) // Default-shaped shelf for the placement preview. Pulls from // `shelfDefinition.defaults()` so the preview matches what the commit @@ -108,6 +50,8 @@ const ShelfTool = () => { useEffect(() => { if (!activeLevelId) return previousSnapRef.current = null + cursorVisibleRef.current = false + setCursorVisible(false) /** * Snapped cursor position from the latest `grid:move`. Used as the * commit position for ANY click variant (grid or node), so clicks @@ -124,33 +68,21 @@ const ShelfTool = () => { let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) const onGridMove = (event: GridEvent) => { - const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) - - // Figma-style alignment snap layered on top of grid snap: when the - // preview shelf's footprint edge lines up (on X or Z) with another - // object's edge, snap there and publish a guide. The probe uses the - // shelf's footprint corners at the proposed grid position so it aligns - // by its edges, not its centre — matching `MoveRegistryNodeTool`. Alt - // bypasses. - let ax = sx - let az = sz - const bypass = event.nativeEvent?.altKey === true - if (!bypass && alignmentCandidates.length > 0) { - const result = resolveAlignment({ - moving: movingFootprintAnchors(previewNode, sx, sz, 0), - candidates: alignmentCandidates, - threshold: ALIGNMENT_THRESHOLD_M, - }) - if (result.snap) { - ax += result.snap.dx - az += result.snap.dz - } - useAlignmentGuides.getState().set(result.guides) - } else { - useAlignmentGuides.getState().clear() + if (!cursorVisibleRef.current) { + cursorVisibleRef.current = true + setCursorVisible(true) } - const position: [number, number, number] = [ax, 0, az] + const { position, guides } = resolveAlignedFloorPlacement({ + node: previewNode, + rawX: event.localPosition[0], + rawZ: event.localPosition[2], + gridStep: useEditor.getState().gridSnapStep, + candidates: alignmentCandidates, + bypassAlignment: event.nativeEvent?.altKey === true, + }) + useAlignmentGuides.getState().set(guides) + const visualPosition = getFloorStackPreviewPosition({ node: previewNode, position, @@ -161,18 +93,20 @@ const ShelfTool = () => { lastCursorRef.current = position const prev = previousSnapRef.current - if (!prev || prev[0] !== ax || prev[1] !== az) { + if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { triggerSFX('sfx:grid-snap') - previousSnapRef.current = [ax, az] + previousSnapRef.current = [position[0], position[2]] } } - const commitAtCursor = (event: ClickTriggerEvent) => { + const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => { // Prefer the latest `grid:move` cursor snapshot; fall back to // projecting the click event into level-local coords if no // grid:move has fired yet (e.g. cursor entered via a node hit // first). Both paths apply the same grid snap. - const position = lastCursorRef.current ?? getLevelLocalPosition(activeLevelId, event) + const position = + lastCursorRef.current ?? + getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep) const shelf = ShelfNode.parse({ ...shelfDefinition.defaults(), name: 'Shelf', @@ -187,33 +121,15 @@ const ShelfTool = () => { alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) useAlignmentGuides.getState().clear() - const native = (event as { nativeEvent?: unknown }).nativeEvent - if ( - native && - typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function' - ) { - ;(native as { stopPropagation: () => void }).stopPropagation() - } - const direct = (event as { stopPropagation?: () => void }).stopPropagation - if (typeof direct === 'function') direct.call(event) + stopPlacementCommitPropagation(event) } emitter.on('grid:move', onGridMove) - emitter.on('grid:click', commitAtCursor) - type SuffixedKey = `${K}:${EventSuffix}` - type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]> - for (const kind of CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.on(key, commitAtCursor as never) - } + const unsubscribePlacementClicks = subscribeFloorPlacementClicks(commitAtCursor) return () => { emitter.off('grid:move', onGridMove) - emitter.off('grid:click', commitAtCursor) - for (const kind of CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.off(key, commitAtCursor as never) - } + unsubscribePlacementClicks() // Drop any alignment guide left over when the tool deactivates (kind // switch, Esc, unmount) so it doesn't linger over the canvas. useAlignmentGuides.getState().clear() @@ -223,7 +139,7 @@ const ShelfTool = () => { if (!activeLevelId) return null return ( - + ) diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index 7c991cb6..151c9141 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -433,8 +433,8 @@ export const stairDefinition: NodeDefinition = { // A stair has no centred box footprint: straight = a cumulative // `stair-segment` chain, curved / spiral = an annular sector. Hand the // alignment bridge the resolved plan `aabb` directly (not a `box`) — the - // stair moves by its origin via `affordanceTools.move`, so it only ever - // contributes static candidate anchors, never the relocatable box path. + // moving-anchor helper can relocate the same shape when a stair is being + // placed or dragged. alignmentFootprint: (node, nodes) => { const aabb = stairFootprintAABB(node as StairNodeType, nodes) return aabb ? { shape: 'aabb', ...aabb } : null diff --git a/packages/nodes/src/stair/floorplan-move.ts b/packages/nodes/src/stair/floorplan-move.ts index ef33a860..e7b13a4d 100644 --- a/packages/nodes/src/stair/floorplan-move.ts +++ b/packages/nodes/src/stair/floorplan-move.ts @@ -3,24 +3,22 @@ import { collectAlignmentAnchors, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + movingAlignmentAnchors, type StairNode, snapScalar, useScene, } from '@pascal-app/core' import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor' +import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' /** * 2D floor-plan move handler for stair. * - * **Pivot semantics.** The stair's ORIGIN (its `position`) follows the - * snapped cursor — the same pivot the 3D move tool (`shared/move-roof-tool`) - * uses: it positions the stair by its origin at the grid-snapped, aligned - * cursor, NOT by the grab offset under the mouse. This replaces the old - * grab-relative delta so dragging in 2D tracks the same point as 3D. + * Existing stairs preserve the cursor grab offset, matching the 3D move + * tools; fresh catalog placement follows the cursor absolutely. * - * Figma alignment is layered on the origin point (single anchor), matching - * `move-roof-tool`'s "align by origin" behaviour; Alt bypasses. Guides are - * cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown. + * Figma alignment is layered on the stair footprint edges; Alt bypasses. + * Guides are cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown. * * The position is written straight to scene each tick (the stair has a real * `position` field, unlike polygon kinds) and re-applied atomically via @@ -29,6 +27,10 @@ import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor' */ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => { const startY = node.position[1] + const resolveCursor = createFloorplanCursorResolver({ + original: [node.position[0], node.position[2]], + metadata: node.metadata, + }) // Alignment candidates gathered once — the scene is stable during the drag. const candidates = collectAlignmentAnchors(nodes, node.id) let lastValid: { position: [number, number, number] } | null = null @@ -39,13 +41,16 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node, // Snap the origin to the editor's current grid step (driven by // `useEditor.gridSnapStep`). Shift bypasses the grid snap. const step = getSegmentGridStep() - const gx = modifiers.shiftKey ? planPoint[0] : snapScalar(planPoint[0], step) - const gz = modifiers.shiftKey ? planPoint[1] : snapScalar(planPoint[1], step) - // Figma alignment on the origin point (Alt bypasses), matching the 3D - // move tool. Publishes guides via `useAlignmentGuides`. + const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step)) + const [gx, gz] = resolveCursor(planPoint, { snap }) + // Figma alignment on the actual stair footprint (Alt bypasses), + // matching the 3D move tool. Publishes guides via `useAlignmentGuides`. + const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0) const { point: aligned } = applyFloorplanAlignment( [gx, gz], - [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }], + movingAnchors.length > 0 + ? movingAnchors + : [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }], candidates, { bypass: modifiers.altKey }, ) diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 0d294bf4..9428a47e 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -3,10 +3,16 @@ import { type FloorplanMoveTarget, type FloorplanMoveTargetSession, useScene, + type WallNode, type WindowNode, } from '@pascal-app/core' import { snapToHalf } from '@pascal-app/editor' -import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target' +import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' +import { + findClosestWallInPlan, + projectWallLocalPointToPlan, + snapLocalXToNeighbors, +} from '../shared/wall-attach-target' import { clampToWall, hasWallChildOverlap } from './window-math' /** @@ -26,6 +32,16 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod const wall = useScene.getState().nodes[node.parentId as AnyNodeId] return wall ? (wall.parentId as AnyNodeId | null) : null })() + const originalWall = node.parentId + ? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined) + : undefined + const resolveCursor = createFloorplanCursorResolver({ + original: + originalWall?.type === 'wall' + ? projectWallLocalPointToPlan(originalWall, node.position[0]) + : [node.position[0], 0], + metadata: node.metadata, + }) // Preserve the source window's local Y — 2D move doesn't have a way // to express vertical motion, so we keep whatever vertical position @@ -46,7 +62,8 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod affectedIds: [node.id as AnyNodeId], apply({ planPoint, modifiers }) { const nodes = useScene.getState().nodes - const hit = findClosestWallInPlan(planPoint, nodes, startLevelId) + const resolvedPlanPoint = resolveCursor(planPoint) + const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) if (!hit) return // Figma-style along-wall alignment first (edge-to-edge with other From f7789a5c7a6556cf7f4e56ebef9410b099d1903e Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 8 Jun 2026 01:10:04 -0400 Subject: [PATCH 5/8] Fix split view rotation sync direction --- packages/editor/src/components/editor/floorplan-panel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 390d3e48..fed7f7be 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -1684,13 +1684,13 @@ function nearestEquivalentDegrees(angle: number, reference: number) { function floorplanRotationFromCameraAzimuth(azimuth: number, reference: number) { return nearestEquivalentDegrees( - radiansToDegrees(azimuth) - FLOORPLAN_VIEW_ROTATION_DEG, + FLOORPLAN_VIEW_ROTATION_DEG - radiansToDegrees(azimuth), reference, ) } function cameraAzimuthFromFloorplanRotation(rotationDeg: number) { - return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG) + return degreesToRadians(FLOORPLAN_VIEW_ROTATION_DEG - rotationDeg) } function floorplanLocalToWorldPoint( From 4445c605324d5d773e7655b1743bf6cddead5877 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 8 Jun 2026 01:14:12 -0400 Subject: [PATCH 6/8] Fix active floorplan rotation feedback --- .../src/components/editor/floorplan-panel.tsx | 71 +++++++++++-------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index fed7f7be..81cb25f0 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -5626,8 +5626,46 @@ export function FloorplanPanel() { }) }) + const applyFloorplanNavigationView = useCallback( + (localCenter: SvgPoint, userRotationDeg: number, viewWidth?: number) => { + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) { + return + } + + const nextSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - buildingRotationDeg + const centerSvg = rotateSvgPoint(localCenter, nextSceneRotationDeg) + const fitted = latestFittedViewportRef.current + const minWidth = fitted ? fitted.width * MIN_VIEWPORT_WIDTH_RATIO : 0.001 + const maxWidth = fitted ? fitted.width * MAX_VIEWPORT_WIDTH_RATIO : Number.POSITIVE_INFINITY + const nextWidth = clamp(viewWidth ?? currentViewport.width, minWidth, maxWidth) + + const nextViewport = { + centerX: centerSvg.x, + centerY: centerSvg.y, + width: nextWidth, + } + + hasUserAdjustedViewportRef.current = true + latestFloorplanUserRotationDegRef.current = userRotationDeg + latestViewportRef.current = nextViewport + setFloorplanUserRotationDeg((current) => + current === userRotationDeg ? current : userRotationDeg, + ) + setViewport((current) => + floorplanViewportEquals(current, nextViewport) ? current : nextViewport, + ) + }, + [buildingRotationDeg], + ) + const syncFloorplanViewportToNavigationPose = useCallback( (pose: NavigationSyncPose) => { + if (floorplanRotationStateRef.current) { + return + } + const nextUserRotationDeg = floorplanRotationFromCameraAzimuth( pose.azimuth, latestFloorplanUserRotationDegRef.current, @@ -5638,37 +5676,10 @@ export function FloorplanPanel() { buildingPosition, buildingRotationY, ) - const nextSceneRotationDeg = - FLOORPLAN_VIEW_ROTATION_DEG + nextUserRotationDeg - buildingRotationDeg - const centerSvg = rotateSvgPoint(localCenter, nextSceneRotationDeg) - const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current - const fitted = latestFittedViewportRef.current - if (!currentViewport) { - return - } - - const minWidth = fitted ? fitted.width * MIN_VIEWPORT_WIDTH_RATIO : 0.001 - const maxWidth = fitted ? fitted.width * MAX_VIEWPORT_WIDTH_RATIO : Number.POSITIVE_INFINITY - const nextWidth = clamp(pose.viewWidth, minWidth, maxWidth) - - const nextViewport = { - centerX: centerSvg.x, - centerY: centerSvg.y, - width: nextWidth, - } - - hasUserAdjustedViewportRef.current = true - latestFloorplanUserRotationDegRef.current = nextUserRotationDeg - latestViewportRef.current = nextViewport - setFloorplanUserRotationDeg((current) => - current === nextUserRotationDeg ? current : nextUserRotationDeg, - ) - setViewport((current) => - floorplanViewportEquals(current, nextViewport) ? current : nextViewport, - ) + applyFloorplanNavigationView(localCenter, nextUserRotationDeg, pose.viewWidth) }, - [buildingPosition, buildingRotationDeg, buildingRotationY], + [applyFloorplanNavigationView, buildingPosition, buildingRotationY], ) useEffect(() => { @@ -7825,6 +7836,7 @@ export function FloorplanPanel() { (event.clientX - rotationState.startClientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg + applyFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg) publishFloorplanNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg) setCursorPoint(null) return @@ -8146,6 +8158,7 @@ export function FloorplanPanel() { isPolygonBuildActive, isRoofBuildActive, isWallBuildActive, + applyFloorplanNavigationView, publishFloorplanNavigationPose, referenceScaleDraft, roofDraftStart, From bd9e97e224c6d560199a92db2db8b247708738ce Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 8 Jun 2026 01:56:45 -0400 Subject: [PATCH 7/8] Stabilize split view navigation sync --- .../editor/custom-camera-controls.tsx | 59 +++++- .../src/components/editor/floorplan-panel.tsx | 182 +++++++++++++++--- 2 files changed, 213 insertions(+), 28 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 4bca280e..59da1f4a 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -43,6 +43,11 @@ type CameraPoseSnapshot = { position: [number, number, number] target: [number, number, number] } +type NavigationCameraPoseSnapshot = { + target: [number, number, number] + azimuth: number + viewWidth: number +} function writeVectorTuple(tuple: [number, number, number], vector: Vector3) { tuple[0] = vector.x @@ -115,6 +120,25 @@ function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewpo return Math.max(0.001, distance) } +function getAngleDeltaRadians(a: number, b: number) { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function isCameraAtNavigationPose( + pose: NavigationCameraPoseSnapshot, + target: Vector3, + azimuth: number, + viewWidth: number, +) { + return ( + Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON && + Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON && + Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON && + Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON && + Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON + ) +} + function getCameraDistanceForViewWidth( camera: Camera, viewWidth: number, @@ -233,11 +257,8 @@ export const CustomCameraControls = () => { ) const currentLevelId = selection.levelId const firstLoad = useRef(true) - const lastPublishedNavigationSync = useRef<{ - target: [number, number, number] - azimuth: number - viewWidth: number - } | null>(null) + const lastPublishedNavigationSync = useRef(null) + const pendingFloorplanNavigationPose = useRef(null) const lastApplied2dNavigationRevision = useRef(0) const maxPolarAngle = !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE @@ -326,6 +347,11 @@ export const CustomCameraControls = () => { if (!control) return lastApplied2dNavigationRevision.current = pose.revision + pendingFloorplanNavigationPose.current = { + target: [...pose.target], + azimuth: pose.azimuth, + viewWidth: pose.viewWidth, + } control.moveTo(pose.target[0], pose.target[1], pose.target[2], true) control.rotateTo(pose.azimuth, control.polarAngle, true) applyCameraViewWidth(control, camera, pose.viewWidth, viewportSize) @@ -339,13 +365,27 @@ export const CustomCameraControls = () => { controls.current.getSpherical(syncSpherical, false) const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize) + const pendingFloorplanPose = pendingFloorplanNavigationPose.current + if (pendingFloorplanPose) { + // The camera is still damping toward a 2D-originated pose; do not echo + // intermediate 3D poses back into the floorplan. + if ( + isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth) + ) { + lastPublishedNavigationSync.current = pendingFloorplanPose + pendingFloorplanNavigationPose.current = null + } + return + } + const previous = lastPublishedNavigationSync.current if ( previous && Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON && Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON && Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON && - Math.abs(previous.azimuth - syncSpherical.theta) < NAVIGATION_SYNC_AZIMUTH_EPSILON && + Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) < + NAVIGATION_SYNC_AZIMUTH_EPSILON && Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON ) { return @@ -564,6 +604,7 @@ export const CustomCameraControls = () => { const onPointerDown = (event: PointerEvent) => { if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return + pendingFloorplanNavigationPose.current = null if (event.button !== 1 && !(event.button === 0 && keyState.space)) return panPointerId = event.pointerId @@ -571,6 +612,10 @@ export const CustomCameraControls = () => { updateNavigationCursor() } + const onWheel = () => { + pendingFloorplanNavigationPose.current = null + } + const onPointerUp = (event: PointerEvent) => { if (panPointerId === null) return if (event.type !== 'pointercancel' && event.pointerId !== panPointerId) return @@ -595,6 +640,7 @@ export const CustomCameraControls = () => { window.addEventListener('pointerup', onPointerUp, true) window.addEventListener('pointercancel', onPointerUp, true) window.addEventListener('blur', onBlur) + gl.domElement.addEventListener('wheel', onWheel, { passive: true }) updateConfig() return () => { @@ -604,6 +650,7 @@ export const CustomCameraControls = () => { window.removeEventListener('pointerup', onPointerUp, true) window.removeEventListener('pointercancel', onPointerUp, true) window.removeEventListener('blur', onBlur) + gl.domElement.removeEventListener('wheel', onWheel) clearNavigationCursor() } }, [cameraMode, gl, isPreviewMode, isFirstPersonMode]) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 81cb25f0..c4e95b30 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -217,18 +217,43 @@ const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1 const FLOORPLAN_SITE_COLOR = '#10b981' const FLOORPLAN_VIEW_ROTATION_DEG = 90 const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35 +const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90 +const FLOORPLAN_VIEW_ANIMATION_EPSILON = 0.0005 +const FLOORPLAN_ROTATION_ANIMATION_EPSILON_DEG = 0.01 type FloorplanViewport = { centerX: number centerY: number width: number } +type FloorplanNavigationViewOptions = { + smooth?: boolean +} + +type FloorplanViewAnimationTarget = { + viewport: FloorplanViewport + userRotationDeg: number + lastTimestamp: number | null +} + function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) { if (a === b) return true if (!(a && b)) return false return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width } +function floorplanViewportWithinEpsilon( + a: FloorplanViewport, + b: FloorplanViewport, + epsilon: number, +) { + return ( + Math.abs(a.centerX - b.centerX) < epsilon && + Math.abs(a.centerY - b.centerY) < epsilon && + Math.abs(a.width - b.width) < epsilon + ) +} + type SvgPoint = { x: number y: number @@ -1684,13 +1709,13 @@ function nearestEquivalentDegrees(angle: number, reference: number) { function floorplanRotationFromCameraAzimuth(azimuth: number, reference: number) { return nearestEquivalentDegrees( - FLOORPLAN_VIEW_ROTATION_DEG - radiansToDegrees(azimuth), + radiansToDegrees(azimuth) - FLOORPLAN_VIEW_ROTATION_DEG, reference, ) } function cameraAzimuthFromFloorplanRotation(rotationDeg: number) { - return degreesToRadians(FLOORPLAN_VIEW_ROTATION_DEG - rotationDeg) + return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG) } function floorplanLocalToWorldPoint( @@ -4272,6 +4297,8 @@ export function FloorplanPanel() { const latestFloorplanUserRotationDegRef = useRef(0) const latestViewportRef = useRef(null) const latestFittedViewportRef = useRef(null) + const floorplanViewAnimationFrameRef = useRef(null) + const floorplanViewAnimationTargetRef = useRef(null) const latestNavigationSyncPoseRef = useRef( useEditor.getState().navigationSyncPose, ) @@ -5626,8 +5653,98 @@ export function FloorplanPanel() { }) }) + const applyFloorplanNavigationState = useCallback( + (nextViewport: FloorplanViewport, userRotationDeg: number) => { + hasUserAdjustedViewportRef.current = true + latestFloorplanUserRotationDegRef.current = userRotationDeg + latestViewportRef.current = nextViewport + setFloorplanUserRotationDeg((current) => + current === userRotationDeg ? current : userRotationDeg, + ) + setViewport((current) => + floorplanViewportEquals(current, nextViewport) ? current : nextViewport, + ) + }, + [], + ) + + const stopFloorplanViewAnimation = useCallback(() => { + if (floorplanViewAnimationFrameRef.current !== null) { + window.cancelAnimationFrame(floorplanViewAnimationFrameRef.current) + floorplanViewAnimationFrameRef.current = null + } + floorplanViewAnimationTargetRef.current = null + }, []) + + const animateFloorplanNavigationState = useCallback( + (nextViewport: FloorplanViewport, userRotationDeg: number) => { + floorplanViewAnimationTargetRef.current = { + viewport: nextViewport, + userRotationDeg, + lastTimestamp: floorplanViewAnimationTargetRef.current?.lastTimestamp ?? null, + } + + if (floorplanViewAnimationFrameRef.current !== null) { + return + } + + const step = (timestamp: number) => { + const target = floorplanViewAnimationTargetRef.current + if (!target) { + floorplanViewAnimationFrameRef.current = null + return + } + + const currentViewport = latestViewportRef.current ?? target.viewport + const currentRotationDeg = latestFloorplanUserRotationDegRef.current + const deltaMs = target.lastTimestamp === null ? 16.67 : timestamp - target.lastTimestamp + target.lastTimestamp = timestamp + const alpha = 1 - Math.exp(-deltaMs / FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS) + const targetRotationDeg = nearestEquivalentDegrees( + target.userRotationDeg, + currentRotationDeg, + ) + const nextRotationDeg = + currentRotationDeg + (targetRotationDeg - currentRotationDeg) * alpha + const animatedViewport = { + centerX: + currentViewport.centerX + (target.viewport.centerX - currentViewport.centerX) * alpha, + centerY: + currentViewport.centerY + (target.viewport.centerY - currentViewport.centerY) * alpha, + width: currentViewport.width + (target.viewport.width - currentViewport.width) * alpha, + } + + const isComplete = + floorplanViewportWithinEpsilon( + animatedViewport, + target.viewport, + FLOORPLAN_VIEW_ANIMATION_EPSILON, + ) && + Math.abs(targetRotationDeg - nextRotationDeg) < FLOORPLAN_ROTATION_ANIMATION_EPSILON_DEG + + if (isComplete) { + applyFloorplanNavigationState(target.viewport, targetRotationDeg) + floorplanViewAnimationTargetRef.current = null + floorplanViewAnimationFrameRef.current = null + return + } + + applyFloorplanNavigationState(animatedViewport, nextRotationDeg) + floorplanViewAnimationFrameRef.current = window.requestAnimationFrame(step) + } + + floorplanViewAnimationFrameRef.current = window.requestAnimationFrame(step) + }, + [applyFloorplanNavigationState], + ) + const applyFloorplanNavigationView = useCallback( - (localCenter: SvgPoint, userRotationDeg: number, viewWidth?: number) => { + ( + localCenter: SvgPoint, + userRotationDeg: number, + viewWidth?: number, + options?: FloorplanNavigationViewOptions, + ) => { const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current if (!currentViewport) { return @@ -5647,19 +5764,34 @@ export function FloorplanPanel() { width: nextWidth, } - hasUserAdjustedViewportRef.current = true - latestFloorplanUserRotationDegRef.current = userRotationDeg - latestViewportRef.current = nextViewport - setFloorplanUserRotationDeg((current) => - current === userRotationDeg ? current : userRotationDeg, - ) - setViewport((current) => - floorplanViewportEquals(current, nextViewport) ? current : nextViewport, - ) + if (options?.smooth) { + animateFloorplanNavigationState(nextViewport, userRotationDeg) + } else { + stopFloorplanViewAnimation() + applyFloorplanNavigationState(nextViewport, userRotationDeg) + } }, - [buildingRotationDeg], + [ + animateFloorplanNavigationState, + applyFloorplanNavigationState, + buildingRotationDeg, + stopFloorplanViewAnimation, + ], ) + const smoothFloorplanNavigationView = useCallback( + (localCenter: SvgPoint, userRotationDeg: number, viewWidth?: number) => { + applyFloorplanNavigationView(localCenter, userRotationDeg, viewWidth, { smooth: true }) + }, + [applyFloorplanNavigationView], + ) + + useEffect(() => { + return () => { + stopFloorplanViewAnimation() + } + }, [stopFloorplanViewAnimation]) + const syncFloorplanViewportToNavigationPose = useCallback( (pose: NavigationSyncPose) => { if (floorplanRotationStateRef.current) { @@ -5764,6 +5896,7 @@ export function FloorplanPanel() { // to the current scene. useEffect(() => { if (!isFloorplanOpen) { + stopFloorplanViewAnimation() floorplanSpacePanPressedRef.current = false panStateRef.current = null floorplanRotationStateRef.current = null @@ -5775,13 +5908,14 @@ export function FloorplanPanel() { setMeasuredSceneBBox(null) if (!latestNavigationSyncPoseRef.current) { + stopFloorplanViewAnimation() hasUserAdjustedViewportRef.current = false latestFloorplanUserRotationDegRef.current = 0 latestViewportRef.current = null setFloorplanUserRotationDeg(0) setViewport(null) } - }, [isFloorplanOpen]) + }, [isFloorplanOpen, stopFloorplanViewAnimation]) useEffect(() => { const levelChanged = previousLevelIdRef.current !== (levelId ?? null) @@ -5789,6 +5923,7 @@ export function FloorplanPanel() { if (levelChanged) { previousLevelIdRef.current = levelId ?? null if (!latestNavigationSyncPoseRef.current) { + stopFloorplanViewAnimation() hasUserAdjustedViewportRef.current = false latestFloorplanUserRotationDegRef.current = 0 latestViewportRef.current = null @@ -5824,6 +5959,7 @@ export function FloorplanPanel() { movingFenceEndpoint, movingNode, siteVertexDragState, + stopFloorplanViewAnimation, ]) const viewBox = useMemo(() => { @@ -6441,11 +6577,6 @@ export function FloorplanPanel() { guideTransformDraftRef.current = guideTransformDraft }, [guideTransformDraft]) - const updateViewport = useCallback((nextViewport: FloorplanViewport) => { - hasUserAdjustedViewportRef.current = true - setViewport(nextViewport) - }, []) - const floorplanGridLocalY = useMemo(() => { if (movingNode?.type === 'item' || movingNode?.type === 'spawn') { return movingNode.position[1] @@ -6661,6 +6792,11 @@ export function FloorplanPanel() { } const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg) + smoothFloorplanNavigationView( + localCenter, + latestFloorplanUserRotationDegRef.current, + nextWidth, + ) publishFloorplanNavigationPose( localCenter, latestFloorplanUserRotationDegRef.current, @@ -6674,6 +6810,7 @@ export function FloorplanPanel() { maxViewportWidth, minViewportWidth, publishFloorplanNavigationPose, + smoothFloorplanNavigationView, svgAspectRatio, viewBox, viewport, @@ -7833,10 +7970,10 @@ export function FloorplanPanel() { event.stopPropagation() const angleDeltaDeg = - (event.clientX - rotationState.startClientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL + (rotationState.startClientX - event.clientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg - applyFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg) + smoothFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg) publishFloorplanNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg) setCursorPoint(null) return @@ -7860,6 +7997,7 @@ export function FloorplanPanel() { FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg) + smoothFloorplanNavigationView(localCenter, currentUserRotationDeg) publishFloorplanNavigationPose(localCenter, currentUserRotationDeg) panStateRef.current = { @@ -8158,8 +8296,8 @@ export function FloorplanPanel() { isPolygonBuildActive, isRoofBuildActive, isWallBuildActive, - applyFloorplanNavigationView, publishFloorplanNavigationPose, + smoothFloorplanNavigationView, referenceScaleDraft, roofDraftStart, elevatorResizeDragState, From 73cd6e86141a441c3aa61e9714505ef7696f3945 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 8 Jun 2026 02:12:01 -0400 Subject: [PATCH 8/8] fix(editor): strip placement metadata on commit --- .../src/components/tools/item/placement-math.test.ts | 10 ++++++++++ .../editor/src/components/tools/item/placement-math.ts | 10 +++++----- packages/nodes/src/door/move-tool.tsx | 2 ++ 3 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 packages/editor/src/components/tools/item/placement-math.test.ts diff --git a/packages/editor/src/components/tools/item/placement-math.test.ts b/packages/editor/src/components/tools/item/placement-math.test.ts new file mode 100644 index 00000000..10bbb8f0 --- /dev/null +++ b/packages/editor/src/components/tools/item/placement-math.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from 'bun:test' +import { stripTransient } from './placement-math' + +describe('stripTransient', () => { + test('removes placement-only metadata flags before commit', () => { + expect(stripTransient({ isNew: true, isTransient: true, label: 'copy' })).toEqual({ + label: 'copy', + }) + }) +}) diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 112273a4..a09c7b27 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -111,13 +111,13 @@ export function isValidWallSideFace(normal: [number, number, number] | undefined return Math.abs(normal[2]) > 0.7 } -/** - * Strip the `isTransient` flag from node metadata before committing. - */ +/** Strip placement-only metadata flags before committing a draft. */ export function stripTransient(meta: any): any { if (!isObject(meta)) return meta - const { isTransient, ...rest } = meta as Record - return rest + const nextMeta = { ...(meta as Record) } + delete nextMeta.isNew + delete nextMeta.isTransient + return nextMeta } const _up = new Vector3(0, 1, 0) diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 66813b67..44bf81cf 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -17,6 +17,7 @@ import { EDITOR_LAYER, getSideFromNormal, isValidWallSideFace, + stripPlacementMetadataFlags, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -275,6 +276,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const cloned = structuredClone(movingDoorNode) as any delete cloned.id + cloned.metadata = stripPlacementMetadataFlags(cloned.metadata) const node = DoorNode.parse({ ...cloned, position: [target.clampedX, target.clampedY, 0],