diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index daffb054..17bb2367 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -88,7 +88,6 @@ export { } from './components/tools/item/use-placement-coordinator' export { CursorSphere } from './components/tools/shared/cursor-sphere' export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' -export { FacingIndicator } from './components/tools/shared/facing-indicator' 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. diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 55fba20d..213d8c98 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -24,6 +24,7 @@ import { triggerSFX, useAlignmentGuides, useEditor, + useFacingPose, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -228,6 +229,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => useAlignmentGuides.getState().clear() clearOpeningGuides3D() setGhostPose(null) + useFacingPose.getState().clear() } // Alignment candidates — only OTHER things on a wall (sibling openings + @@ -374,18 +376,32 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => target.wallNode.end[1] - target.wallNode.start[1], target.wallNode.end[0] - target.wallNode.start[0], ) + const ghostWorldPos = wallLocalToWorld( + target.wallNode, + target.clampedX, + target.clampedY, + getLevelYOffset(), + getSlabElevation(target.event), + ) + const ghostYaw = target.itemRotation - wallAngle setGhostPose({ - position: wallLocalToWorld( - target.wallNode, - target.clampedX, - target.clampedY, - getLevelYOffset(), - getSlabElevation(target.event), - ), - rotationY: target.itemRotation - wallAngle, + position: ghostWorldPos, + rotationY: ghostYaw, tint: placement.tint, side: target.side, }) + // Forward-facing triangle (editor-side overlay), in the same building-local + // frame the ghost renders in. The door's front is its local +Z. Drop it to + // the floor under the wall (the ghost Y is the opening centre, ~1m up). + useFacingPose.getState().set({ + position: [ + ghostWorldPos[0], + getLevelYOffset() + getSlabElevation(target.event), + ghostWorldPos[2], + ], + rotationY: ghostYaw, + depth: movingDoorNode.frameDepth ?? 0.07, + }) publishOpeningGuidesForWallEvent({ wall: target.wallNode, @@ -552,6 +568,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // which previews with the real mesh (the ghost-tint flow is wall-specific). const revealRealNode = () => { setGhostPose(null) + useFacingPose.getState().clear() const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined if (live && live.visible === false) { useScene.getState().updateNode(movingDoorNode.id, { visible: true }) @@ -612,6 +629,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => tint: 'invalid', side: sideOverride, }) + // Off-wall (no host) floating ghost — no direction triangle. + useFacingPose.getState().clear() } const onGridMove = (event: GridEvent) => { @@ -918,6 +937,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => useLiveTransforms.getState().clear(movingDoorNode.id) useAlignmentGuides.getState().clear() clearOpeningGuides3D() + useFacingPose.getState().clear() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 8a666612..4ce1286b 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -16,13 +16,13 @@ import { calculateCursorRotation, calculateItemRotation, EDITOR_LAYER, - FacingIndicator, getSideFromNormal, isMagneticSnapActive, isValidWallSideFace, triggerSFX, useAlignmentGuides, useEditor, + useFacingPose, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -75,7 +75,6 @@ type HostKind = 'wall' | 'roof' | null const DoorTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) - const indicatorYOffsetRef = useRef(null!) const edgesRef = useRef(null!) // Off-host floating ghost: the real door geometry follows the cursor over @@ -99,6 +98,10 @@ const DoorTool: React.FC = () => { }), [fallbackPose?.side], ) + // The frame depth is a fixed parse default (the `side` flip doesn't change + // it); a ref lets the facing-pose publish inside the setup effect read it + // without re-subscribing every event listener. + const frameDepthRef = useRef(ghostStub.frameDepth) useEffect(() => { useScene.temporal.getState().pause() @@ -145,6 +148,7 @@ const DoorTool: React.FC = () => { useAlignmentGuides.getState().clear() clearOpeningGuides3D() setFallbackPose(null) + useFacingPose.getState().clear() } // Alignment candidates — anchors of every alignable object; refreshed @@ -166,8 +170,15 @@ const DoorTool: React.FC = () => { group.visible = true group.position.set(...worldPosition) group.rotation.y = cursorRotationY - indicatorYOffsetRef.current?.position.set(0, indicatorYOffset, 0) edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) + // Forward-facing triangle (editor-side overlay). The cursor group is + // already yawed so +Z faces out of the wall, so the door's front is +Z. + // The indicator rides at the sill (`indicatorYOffset`, the door's base). + useFacingPose.getState().set({ + position: [worldPosition[0], worldPosition[1] + indicatorYOffset, worldPosition[2]], + rotationY: cursorRotationY, + depth: frameDepthRef.current, + }) } // Off-host fallback: hide the wireframe outline and float the real door @@ -183,6 +194,8 @@ const DoorTool: React.FC = () => { }) useAlignmentGuides.getState().clear() clearOpeningGuides3D() + // Off-host (invalid) floating ghost — no direction triangle. + useFacingPose.getState().clear() } const showRoofFallbackCursor = (event: RoofEvent) => { @@ -677,9 +690,6 @@ const DoorTool: React.FC = () => { material={edgeMaterial} ref={edgesRef} /> - - - {fallbackPose && ( diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index bab067a8..686a8965 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -25,6 +25,7 @@ import { triggerSFX, useAlignmentGuides, useEditor, + useFacingPose, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -261,6 +262,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useAlignmentGuides.getState().clear() clearOpeningGuides3D() setGhostPose(null) + useFacingPose.getState().clear() } // Alignment candidates — only OTHER things on a wall (sibling openings + @@ -413,19 +415,33 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode target.wallNode.end[1] - target.wallNode.start[1], target.wallNode.end[0] - target.wallNode.start[0], ) + const ghostWorldPos = wallLocalToWorld( + target.wallNode, + target.clampedX, + target.clampedY, + getLevelYOffset(), + getSlabElevation(target.event), + ) + const ghostYaw = target.itemRotation - wallAngle setGhostPose({ - position: wallLocalToWorld( - target.wallNode, - target.clampedX, - target.clampedY, - getLevelYOffset(), - getSlabElevation(target.event), - ), - rotationY: target.itemRotation - wallAngle, + position: ghostWorldPos, + rotationY: ghostYaw, tint: placement.tint, floorY: getLevelYOffset() + getSlabElevation(target.event), side: target.side, }) + // Forward-facing triangle (editor-side overlay), in the same building-local + // frame the ghost renders in. The window's front is its local +Z. Drop it + // to the floor under the wall (the ghost Y is the sill centre, up the wall). + useFacingPose.getState().set({ + position: [ + ghostWorldPos[0], + getLevelYOffset() + getSlabElevation(target.event), + ghostWorldPos[2], + ], + rotationY: ghostYaw, + depth: movingWindowNode.frameDepth ?? 0.07, + }) publishOpeningGuidesForWallEvent({ wall: target.wallNode, @@ -595,6 +611,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // which previews with the real mesh (the ghost-tint flow is wall-specific). const revealRealNode = () => { setGhostPose(null) + useFacingPose.getState().clear() const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as | WindowNode | undefined @@ -652,6 +669,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode floorY: getLevelYOffset(), side: sideOverride, }) + // Off-wall (no host) floating ghost — no direction triangle. + useFacingPose.getState().clear() } const onGridMove = (event: GridEvent) => { @@ -953,6 +972,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useLiveTransforms.getState().clear(movingWindowNode.id) useAlignmentGuides.getState().clear() clearOpeningGuides3D() + useFacingPose.getState().clear() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index e7e77b00..137d7229 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -16,7 +16,6 @@ import { calculateCursorRotation, calculateItemRotation, EDITOR_LAYER, - FacingIndicator, getSideFromNormal, isMagneticSnapActive, isValidWallSideFace, @@ -24,6 +23,7 @@ import { triggerSFX, useAlignmentGuides, useEditor, + useFacingPose, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -88,7 +88,6 @@ type HostKind = 'wall' | 'roof' | null const WindowTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) - const indicatorYOffsetRef = useRef(null!) const edgesRef = useRef(null!) // Off-host floating ghost: the real window geometry follows the cursor @@ -112,6 +111,10 @@ const WindowTool: React.FC = () => { }), [fallbackPose?.side], ) + // The frame depth is a fixed parse default (the `side` flip doesn't change + // it); a ref lets the facing-pose publish inside the setup effect read it + // without re-subscribing every event listener. + const frameDepthRef = useRef(ghostStub.frameDepth) useEffect(() => { useScene.temporal.getState().pause() @@ -158,6 +161,7 @@ const WindowTool: React.FC = () => { useAlignmentGuides.getState().clear() clearOpeningGuides3D() setFallbackPose(null) + useFacingPose.getState().clear() } // Alignment candidates — anchors of every alignable object; refreshed @@ -180,8 +184,15 @@ const WindowTool: React.FC = () => { group.visible = true group.position.set(...worldPosition) group.rotation.y = cursorRotationY - indicatorYOffsetRef.current?.position.set(0, indicatorYOffset, 0) edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) + // Forward-facing triangle (editor-side overlay). The cursor group is + // already yawed so +Z faces out of the wall, so the window's front is +Z. + // The indicator rides at the sill (`indicatorYOffset`). + useFacingPose.getState().set({ + position: [worldPosition[0], worldPosition[1] + indicatorYOffset, worldPosition[2]], + rotationY: cursorRotationY, + depth: frameDepthRef.current, + }) } // Off-host fallback: hide the wireframe outline and float the real window @@ -198,6 +209,8 @@ const WindowTool: React.FC = () => { }) useAlignmentGuides.getState().clear() clearOpeningGuides3D() + // Off-host (invalid) floating ghost — no direction triangle. + useFacingPose.getState().clear() } const showRoofFallbackCursor = (event: RoofEvent) => { @@ -730,9 +743,6 @@ const WindowTool: React.FC = () => { material={edgeMaterial} ref={edgesRef} /> - - - {fallbackPose && (