feat(editor): route door/window facing triangle through the unified overlay
Door and window are placed via preset and moved with a bespoke wall-bound `move-tool` (affordanceTools.move), not the draw tool — so the previous inline triangle never showed for the paths actually used. Migrate both: - move-tool (move + preset, the community path): publish the on-wall ghost pose to `useFacingPose` in the same building-local frame the ghost renders in, dropped to the floor under the wall (the ghost Y is the opening centre); clear on every off-wall / hide / reveal / unmount path - draw tool (standalone from-scratch path): publish the on-host pose, clear on fallback/hide; frame depth read via a ref to keep the setup effect deps clean Removes the now-dead `FacingIndicator` public export (the editor-side overlay is its only consumer, via relative import). The unified overlay now covers every placement/move path: items, column/shelf, stair, and door/window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
68e5c6ca67
commit
67a558a883
@@ -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)
|
||||
|
||||
@@ -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<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
|
||||
@@ -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}
|
||||
/>
|
||||
<group ref={indicatorYOffsetRef}>
|
||||
<FacingIndicator depth={ghostStub.frameDepth} />
|
||||
</group>
|
||||
</group>
|
||||
{fallbackPose && (
|
||||
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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
|
||||
@@ -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}
|
||||
/>
|
||||
<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