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:
co-authored by
Claude Opus 4.8
parent
76089d85ea
commit
68e5c6ca67
@@ -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',
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user