feat(editor): unify placement/move facing triangle into one editor-side renderer

Every placement and move path now publishes its ghost pose to a single
`useFacingPose` store, drawn by one editor-side `<FacingPoseIndicator>` overlay,
instead of each path drawing its own triangle (which left the nodes-package and
PlacementBox paths invisible):

- column/shelf presets + all moves (PlacementBox via move-registry, and
  DragBoundingBox) now publish the pose, so the triangle finally shows
- stair create + move use a declarative `facingIndicator: { reversed: true }`
  (new registry resolver) so the triangle sits before the entry pointing out —
  resolved in one place, so create and move match automatically
- stair placement defaults to single and respects the shared `point`
  continuation (C) toggle, like the other placement tools

Checkpoint on the placement-interaction epic: also carries the in-flight
continuation-profile extraction (lib/continuation), grid surface (item #8), and
HUD work. Door/window still render their own legacy inline triangle and are
migrated to the overlay next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-26 21:48:58 -04:00
co-authored by Claude Opus 4.8
parent 76089d85ea
commit 68e5c6ca67
47 changed files with 1285 additions and 505 deletions
+1
View File
@@ -315,6 +315,7 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'column',
snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1,
schema: ColumnNode,
category: 'structure',
+18 -4
View File
@@ -16,6 +16,7 @@ import {
triggerSFX,
useAlignmentGuides,
useEditor,
useFacingPose,
usePlacementPreview,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
@@ -101,6 +102,13 @@ const ColumnTool = () => {
levelId: activeLevelId,
})
cursorRef.current?.position.set(...visualPosition)
// Forward-facing floor triangle, drawn by the editor-side overlay. Columns
// never rotate (`rotation: 0`), so the triangle just sits in front.
useFacingPose.getState().set({
position: visualPosition,
rotationY: previewNode.rotation,
depth: previewNode.depth,
})
lastCursorRef.current = position
// Publish a transient, positioned preview node for the 2D floor-plan
@@ -130,12 +138,17 @@ const ColumnTool = () => {
useScene.getState().createNode(column, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [column.id] })
triggerSFX('sfx:structure-build')
// The placed column is now a valid alignment target for the next one;
// refresh the candidate pool and drop the guide from this drop. The
// 2D ghost re-publishes on the next move.
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear()
if (useEditor.getState().getContinuation('point') === 'repeat') {
// The placed column is now a valid alignment target for the next one.
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
} else {
cursorVisibleRef.current = false
setCursorVisible(false)
useFacingPose.getState().clear()
useEditor.getState().setTool(null)
}
stopPlacementCommitPropagation(event)
}
@@ -147,6 +160,7 @@ const ColumnTool = () => {
unsubscribePlacementClicks()
useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear()
useFacingPose.getState().clear()
}
}, [activeLevelId, previewNode])
+1
View File
@@ -167,6 +167,7 @@ const doorHandles: HandleDescriptor<DoorNodeType>[] = [
export const doorDefinition: NodeDefinition<typeof DoorNode> = {
kind: 'door',
snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1,
schema: DoorNode,
category: 'structure',
+23 -4
View File
@@ -16,11 +16,13 @@ import {
calculateCursorRotation,
calculateItemRotation,
EDITOR_LAYER,
FacingIndicator,
getSideFromNormal,
isMagneticSnapActive,
isValidWallSideFace,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -73,6 +75,7 @@ type HostKind = 'wall' | 'roof' | null
const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const indicatorYOffsetRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
// Off-host floating ghost: the real door geometry follows the cursor over
@@ -155,6 +158,7 @@ const DoorTool: React.FC = () => {
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
indicatorYOffset: number,
) => {
setFallbackPose(null)
const group = cursorGroupRef.current
@@ -162,6 +166,7 @@ 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)
}
@@ -282,6 +287,7 @@ const DoorTool: React.FC = () => {
),
cursorRotationY,
valid,
-clampedY,
)
if (draftRef.current) {
@@ -358,11 +364,16 @@ const DoorTool: React.FC = () => {
useScene.getState().createNode(node, wall.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
if (useEditor.getState().getContinuation('point') === 'repeat') {
useScene.temporal.getState().pause()
alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
} else {
hideCursor()
useEditor.getState().setTool(null)
}
}
// ── Direct wall-mesh hover ──────────────────────────────────────
@@ -469,7 +480,7 @@ const DoorTool: React.FC = () => {
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const pose = getRoofWallOpeningCursorPose(target, roof)
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
if (pose) updateCursor(pose.position, pose.rotationY, target.valid, -target.position[1])
}
const onRoofHover = (event: RoofEvent) => {
@@ -568,8 +579,13 @@ const DoorTool: React.FC = () => {
// picks up the new opening cut.
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
if (useEditor.getState().getContinuation('point') === 'repeat') {
useScene.temporal.getState().pause()
} else {
hideCursor()
useEditor.getState().setTool(null)
}
event.stopPropagation()
}
@@ -661,6 +677,9 @@ const DoorTool: React.FC = () => {
material={edgeMaterial}
ref={edgesRef}
/>
<group ref={indicatorYOffsetRef}>
<FacingIndicator depth={ghostStub.frameDepth} />
</group>
</group>
{fallbackPose && (
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
+2 -2
View File
@@ -500,7 +500,7 @@ export const FenceTool: React.FC = () => {
// While drafting, the segment locks to 15° rays from its start.
// Snapping is governed by the snapping mode (`'off'` is the bypass);
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap
// mode, not Alt (Alt-tap toggles continuous/single chaining).
// mode, not Alt (continuation is cycled through the HUD / C).
const bypassAlign = !isMagneticSnapActive()
if (buildingState.current === 1) {
@@ -614,7 +614,7 @@ export const FenceTool: React.FC = () => {
// Single mode commits one segment per click: stop drafting so the next
// click starts a fresh segment instead of chaining off this endpoint.
if (useEditor.getState().fenceChainMode === 'single') {
if (useEditor.getState().getContinuation('fence') === 'single') {
stopDrafting()
return
}
+1
View File
@@ -167,6 +167,7 @@ function itemWallMoveHandle(): HandleDescriptor<ItemNodeType> {
export const itemDefinition: NodeDefinition<typeof ItemNode> = {
kind: 'item',
snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1,
schema: ItemNode,
category: 'furnish',
+1 -4
View File
@@ -35,10 +35,7 @@ function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) {
},
onCommitted: () => {
triggerSFX('sfx:item-place')
// Returning `true` tells the coordinator to immediately spawn the
// next draft so the user can keep placing copies — matches the
// "repeat-on-click" UX of the legacy tool.
return true
return useEditor.getState().getContinuation('point') === 'repeat'
},
})
+1
View File
@@ -133,6 +133,7 @@ function shelfHandles(_node: ShelfNodeType): HandleDescriptor<ShelfNodeType>[] {
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
kind: 'shelf',
snapProfile: 'item',
facingIndicator: true,
schemaVersion: 2,
schema: ShelfNode,
category: 'furnish',
+8 -3
View File
@@ -128,10 +128,15 @@ const ShelfTool = () => {
useScene.getState().createNode(shelf, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [shelf.id] })
triggerSFX('sfx:item-place')
// The placed shelf is now a valid alignment target for the next one;
// refresh the candidate pool and drop the guide from this drop.
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
useAlignmentGuides.getState().clear()
if (useEditor.getState().getContinuation('point') === 'repeat') {
// The placed shelf is now a valid alignment target for the next one.
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
} else {
cursorVisibleRef.current = false
setCursorVisible(false)
useEditor.getState().setTool(null)
}
stopPlacementCommitPropagation(event)
}
+4
View File
@@ -422,6 +422,10 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
schema: StairNode,
category: 'structure',
snapProfile: 'structural',
// A footprint with a clear front: you approach a stair from the low end,
// which sits on the -Z side of the run (the run ascends along +Z). Show the
// floor facing triangle there, pointing out of the entry, while placing/moving.
facingIndicator: { reversed: true },
// Placed as a footprint (R/T rotates), not a directional draw → no angle-lock
// mode. The toolHints presence routes it through the contextual HUD so the
// snapping chip shows during placement.
+1 -2
View File
@@ -705,8 +705,7 @@ export const WallTool: React.FC = () => {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
const wallChainMode = useEditor.getState().wallChainMode
if (wallChainMode === 'single') {
if (useEditor.getState().getContinuation('wall') === 'single') {
stopDrafting()
return
}
+1
View File
@@ -161,6 +161,7 @@ const windowHandles: HandleDescriptor<WindowNodeType>[] = [
export const windowDefinition: NodeDefinition<typeof WindowNode> = {
kind: 'window',
snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1,
schema: WindowNode,
category: 'structure',
+23 -4
View File
@@ -16,12 +16,14 @@ import {
calculateCursorRotation,
calculateItemRotation,
EDITOR_LAYER,
FacingIndicator,
getSideFromNormal,
isMagneticSnapActive,
isValidWallSideFace,
snapToHalf,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -86,6 +88,7 @@ type HostKind = 'wall' | 'roof' | null
const WindowTool: React.FC = () => {
const draftRef = useRef<WindowNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const indicatorYOffsetRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
// Off-host floating ghost: the real window geometry follows the cursor
@@ -169,6 +172,7 @@ const WindowTool: React.FC = () => {
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
indicatorYOffset: number,
) => {
setFallbackPose(null)
const group = cursorGroupRef.current
@@ -176,6 +180,7 @@ 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)
}
@@ -341,6 +346,7 @@ const WindowTool: React.FC = () => {
),
cursorRotationY,
valid,
-clampedY,
)
if (draftRef.current) {
@@ -411,11 +417,16 @@ const WindowTool: React.FC = () => {
useScene.getState().createNode(node, wall.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
if (useEditor.getState().getContinuation('point') === 'repeat') {
useScene.temporal.getState().pause()
alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
} else {
hideCursor()
useEditor.getState().setTool(null)
}
}
// ── Direct wall-mesh hover ──────────────────────────────────────
@@ -531,7 +542,7 @@ const WindowTool: React.FC = () => {
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const pose = getRoofWallOpeningCursorPose(target, roof)
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
if (pose) updateCursor(pose.position, pose.rotationY, target.valid, -target.position[1])
}
const onRoofHover = (event: RoofEvent) => {
@@ -624,8 +635,13 @@ const WindowTool: React.FC = () => {
// picks up the new opening cut.
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
if (useEditor.getState().getContinuation('point') === 'repeat') {
useScene.temporal.getState().pause()
} else {
hideCursor()
useEditor.getState().setTool(null)
}
event.stopPropagation()
}
@@ -714,6 +730,9 @@ const WindowTool: React.FC = () => {
material={edgeMaterial}
ref={edgesRef}
/>
<group ref={indicatorYOffsetRef}>
<FacingIndicator depth={ghostStub.frameDepth} />
</group>
</group>
{fallbackPose && (
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>