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
@@ -30,6 +30,7 @@ export {
nodeRegistry, nodeRegistry,
type PluginDiscovery, type PluginDiscovery,
registerNode, registerNode,
resolveFacingIndicator,
setPluginDiscovery, setPluginDiscovery,
} from './registry' } from './registry'
export { export {
+12
View File
@@ -178,6 +178,18 @@ export function isPresettableKind(kind: string): boolean {
return def ? isPresettable(def) : false return def ? isPresettable(def) : false
} }
/**
* Resolve a kind's facing-triangle config, or `null` when it has none.
* `{ reversed }` says whether the triangle points along the node's local -Z
* (its front) instead of +Z. One reader (the editor-side `<FacingPoseIndicator>`
* publishers) so placement and move stay consistent.
*/
export function resolveFacingIndicator(kind: string): { reversed: boolean } | null {
const facing = nodeRegistry.get(kind)?.facingIndicator
if (!facing) return null
return { reversed: facing === true ? false : (facing.reversed ?? false) }
}
/** /**
* Names of schema fields on `def` that are host references (`wallId`, * Names of schema fields on `def` that are host references (`wallId`,
* `wallT`, etc.). Read by host apps at preset-save time to strip these * `wallT`, etc.). Read by host apps at preset-save time to strip these
+8
View File
@@ -732,6 +732,14 @@ export type NodeDefinition<S extends ZodObject<any>> = {
schema: S schema: S
category: NodeCategory category: NodeCategory
surfaceRole?: SurfaceRole surfaceRole?: SurfaceRole
/**
* Show a floor direction-triangle while placing/moving — the kind has a
* meaningful front. `true` points along the node's local +Z (forward).
* `{ reversed: true }` points along local -Z, for kinds whose front is the
* -Z side (a stair faces *out* of its run: you approach from the low end,
* which sits on the -Z side of the footprint).
*/
facingIndicator?: boolean | { reversed?: boolean }
/** /**
* Role this kind plays in a distribution system (HVAC duct / DWV pipe / * Role this kind plays in a distribution system (HVAC duct / DWV pipe /
* refrigerant lineset). Lets the system-graph summary classify a * refrigerant lineset). Lets the system-graph summary classify a
@@ -1153,12 +1153,12 @@ export const CustomCameraControls = () => {
}, []) }, [])
// Preset capture mode frames a single subtree (often a 0.32m preset), // Preset capture mode frames a single subtree (often a 0.32m preset),
// so the default 6m minDistance prevents the user from getting close // so the default 2m minDistance prevents the user from getting close
// enough to compose a good thumbnail. Relax the clamp to 0.5m while // enough to compose a good thumbnail. Relax the clamp to 0.5m while
// capturing presets; reset on exit so general editing keeps the looser // capturing presets; reset on exit so general editing keeps the looser
// navigation guardrails. // navigation guardrails.
const isPresetCapture = captureMode.mode === 'preset' const isPresetCapture = captureMode.mode === 'preset'
const minDistance = isPresetCapture ? 0.5 : 6 const minDistance = isPresetCapture ? 0.5 : 2
if (isFirstPersonMode) { if (isFirstPersonMode) {
return null return null
@@ -8,6 +8,7 @@ import {
DEFAULT_WALL_HEIGHT, DEFAULT_WALL_HEIGHT,
DoorNode, DoorNode,
ElevatorNode, ElevatorNode,
emitter,
FenceNode, FenceNode,
generateId, generateId,
getActiveRoofHeight, getActiveRoofHeight,
@@ -215,6 +216,7 @@ export function FloatingActionMenu() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const canFindNode = useEditor((s) => s.canFindNode)
const endpointReshape = useEndpointReshape() const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape() const isCurveReshape = useIsCurveReshape()
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
@@ -655,6 +657,16 @@ export function FloatingActionMenu() {
[node?.type, selectedId, setSelection], [node?.type, selectedId, setSelection],
) )
// "Find in catalog": the editor only signals intent — the host (community)
// listens for `selection:find-node` and reveals the node in its browser.
const handleFind = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (node) emitter.emit('selection:find-node' as never, node as never)
},
[node],
)
if ( if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
endpointReshape || endpointReshape ||
@@ -676,6 +688,7 @@ export function FloatingActionMenu() {
> >
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}> <div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
<NodeActionMenu <NodeActionMenu
onFind={node && canFindNode ? handleFind : undefined}
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined} onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={ onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall) node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
@@ -8871,7 +8871,7 @@ export function FloorplanPanel({
// While a draft is open the segment locks to 15° rays from its start. // While a draft is open the segment locks to 15° rays from its start.
// Snapping is governed by the snapping mode (`'off'` is the bypass); // Snapping is governed by the snapping mode (`'off'` is the bypass);
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap // 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 fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive() const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
const fenceSnapped = snapFenceDraftPoint({ const fenceSnapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
@@ -9361,7 +9361,7 @@ export function FloorplanPanel({
: (publishedNextStart ?? point) : (publishedNextStart ?? point)
if ( if (
useEditor.getState().wallChainMode === 'single' || useEditor.getState().getContinuation('wall') === 'single' ||
(wallChainFirstVertex && isWithinWallJoinSnapRadius(nextStart, wallChainFirstVertex)) (wallChainFirstVertex && isWithinWallJoinSnapRadius(nextStart, wallChainFirstVertex))
) { ) {
clearWallPlacementDraft() clearWallPlacementDraft()
+104 -33
View File
@@ -1,14 +1,28 @@
'use client' 'use client'
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer' import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { MathUtils, type Mesh, PlaneGeometry, Vector2 } from 'three' import { MathUtils, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three'
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl' import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useCeilingEvents } from '../../hooks/use-ceiling-events' import { useCeilingEvents } from '../../hooks/use-ceiling-events'
import { useGridEvents } from '../../hooks/use-grid-events' import { useGridEvents } from '../../hooks/use-grid-events'
import { getPlacementSurface } from '../../lib/active-placement-surface'
import useEditor, { isGridSnapActive } from '../../store/use-editor'
import useInteractionScope, { getMovingNode } from '../../store/use-interaction-scope'
// Reveal radius (m) of the cursor-local grid patch shown while placing/moving in
// grid-snap mode — much tighter than the idle reveal so only the area you're
// about to snap into lights up.
const PLACEMENT_REVEAL_RADIUS = 5
const UP = new Vector3(0, 1, 0)
// PlaneGeometry faces +Z; this is the orientation that lays it flat (its normal
// → world +Y), equivalent to the old `rotation-x={-π/2}`.
const PLANE_LOCAL_NORMAL = new Vector3(0, 0, 1)
const HORIZONTAL_QUATERNION = new Quaternion().setFromUnitVectors(PLANE_LOCAL_NORMAL, UP)
export const Grid = ({ export const Grid = ({
cellSize = 0.5, cellSize = 0.5,
@@ -38,6 +52,16 @@ export const Grid = ({
const effectiveSectionColor = isDark ? '#666677' : sectionColor const effectiveSectionColor = isDark ? '#666677' : sectionColor
const cursorPositionRef = useRef(new Vector2(0, 0)) const cursorPositionRef = useRef(new Vector2(0, 0))
// Scratch for reading a moving node's world Y (surface elevation) each frame.
const worldPosRef = useRef(new Vector3())
// Reveal radius + baseline alpha are uniforms so a placement/move can shrink
// the grid to a tight cursor patch (and drop the always-on baseline) without
// rebuilding the shader. Driven each frame in `useFrame`.
const revealRadiusUniform = useMemo(() => uniform(revealRadius), [revealRadius])
const baseAlphaUniform = useMemo(() => uniform(0.4), [])
const cellSizeUniform = useMemo(() => uniform(cellSize), [cellSize])
const patchAlphaUniform = useMemo(() => uniform(1), [])
const material = useMemo(() => { const material = useMemo(() => {
// Use xy since plane geometry is in XY space (before rotation) // Use xy since plane geometry is in XY space (before rotation)
@@ -48,7 +72,7 @@ export const Grid = ({
// Grid line function using fwidth for anti-aliasing // Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere // Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => { const getGrid = (size: number | typeof cellSizeUniform, thickness: number) => {
const r = pos.div(size) const r = pos.div(size)
const fw = fwidth(r) const fw = fwidth(r)
// Distance to nearest grid line for each axis // Distance to nearest grid line for each axis
@@ -70,7 +94,7 @@ export const Grid = ({
return lineX.max(lineY) return lineX.max(lineY)
} }
const g1 = getGrid(cellSize, cellThickness) const g1 = getGrid(cellSizeUniform, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness) const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center // Distance fade from center
@@ -79,7 +103,9 @@ export const Grid = ({
// Cursor reveal effect - distance from cursor // Cursor reveal effect - distance from cursor
const cursorDist = pos.sub(cursorPos).length() const cursorDist = pos.sub(cursorPos).length()
const cursorFade = float(1).sub(cursorDist.div(revealRadius).clamp(0, 1)).smoothstep(0, 1) const cursorFade = float(1)
.sub(cursorDist.div(revealRadiusUniform).clamp(0, 1))
.smoothstep(0, 1)
// Mix colors based on section grid // Mix colors based on section grid
const gridColor = mix( const gridColor = mix(
@@ -88,12 +114,10 @@ export const Grid = ({
float(sectionThickness).mul(g2).min(1), float(sectionThickness).mul(g2).min(1),
) )
// Baseline alpha: small amount of opacity everywhere the grid exists
const baseAlpha = float(0.4) // Subtle global visibility
// Combined alpha with cursor fade and baseline minimum // Combined alpha with cursor fade and baseline minimum
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha)) const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlphaUniform))
const finalAlpha = mix(alpha.mul(0.75), alpha, g2) const boostedAlpha = alpha.mul(patchAlphaUniform).min(1)
const finalAlpha = mix(boostedAlpha.mul(0.75), boostedAlpha, g2)
return new MeshBasicNodeMaterial({ return new MeshBasicNodeMaterial({
transparent: true, transparent: true,
@@ -102,7 +126,6 @@ export const Grid = ({
depthWrite: false, depthWrite: false,
}) })
}, [ }, [
cellSize,
cellThickness, cellThickness,
effectiveCellColor, effectiveCellColor,
sectionSize, sectionSize,
@@ -110,7 +133,10 @@ export const Grid = ({
effectiveSectionColor, effectiveSectionColor,
fadeDistance, fadeDistance,
fadeStrength, fadeStrength,
revealRadius, revealRadiusUniform,
baseAlphaUniform,
cellSizeUniform,
patchAlphaUniform,
]) ])
const gridRef = useRef<Mesh>(null!) const gridRef = useRef<Mesh>(null!)
@@ -145,30 +171,80 @@ export const Grid = ({
useFrame((_, delta) => { useFrame((_, delta) => {
const { levelId } = useViewer.getState().selection const { levelId } = useViewer.getState().selection
// Grid stays anchored to world XZ (0, 0) — never chases the active let levelY = 0
// building. The Y origin still lerps to the active level so the grid
// sits at floor height when a level is open.
let targetY = 0
if (levelId) { if (levelId) {
const levelMesh = sceneRegistry.nodes.get(levelId) const levelMesh = sceneRegistry.nodes.get(levelId)
if (levelMesh) { if (levelMesh) {
targetY = levelMesh.position.y levelY = levelMesh.position.y
} }
} }
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
gridRef.current.position.y = newY
setGridY(newY)
// Grid XZ is fixed at world origin, so the local-frame cursor uniform // Resolve the surface the active ghost is snapped to (contact point +
// is just the world cursor (mirrored on Z to match the -π/2 X-rotation // normal). A fresh GLB item / drawn kind publishes via the surface module; a
// of the plane). // moving node is read straight off its mesh (treated as horizontal). Null
// when nothing is being placed.
const published = getPlacementSurface()
const movingForGrid = getMovingNode()
let surfacePoint: Vector3 | null = null
let surfaceNormal = UP
if (published) {
surfacePoint = published.point
surfaceNormal = published.normal
} else if (movingForGrid) {
const ghostMesh = sceneRegistry.nodes.get(movingForGrid.id as AnyNodeId)
if (ghostMesh) surfacePoint = ghostMesh.getWorldPosition(worldPosRef.current)
}
const gridMesh = gridRef.current
const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
if (onWall && surfacePoint) {
// Vertical surface: drop the plane onto the wall at the contact point and
// orient it into the wall plane. The patch reveals centred there — a wall
// has no world-anchored floor lattice to track.
gridMesh.position.copy(surfacePoint)
gridMesh.quaternion.setFromUnitVectors(PLANE_LOCAL_NORMAL, surfaceNormal)
cursorPositionRef.current.set(0, 0)
setGridY(surfacePoint.y)
} else {
// Horizontal: keep the lattice anchored to world XZ (0,0); only the Y
// origin follows the surface height (floor / shelf top), lerped. Cursor
// uniform tracks the world cursor (mirrored on Z for the laid-flat plane).
const targetY = surfacePoint ? surfacePoint.y : levelY
const newY = MathUtils.lerp(gridMesh.position.y, targetY, 12 * delta)
gridMesh.position.set(0, newY, 0)
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
const world = lastWorldCursorRef.current const world = lastWorldCursorRef.current
if (world) { if (world) {
cursorPositionRef.current.set(world.x, -world.z) cursorPositionRef.current.set(world.x, -world.z)
} }
}) setGridY(newY)
}
const showGrid = useViewer((state) => state.showGrid) // While placing/moving: in grid-snap mode shrink to a tight cursor patch
// (drop the always-on baseline so only the snap area near the cursor shows);
// when NOT grid-snapping, hide the grid entirely. Idle keeps the full grid.
// "Actively placing/moving" means a ghost is being positioned: a movingNode
// (preset/node move), an in-progress draft (wall/fence), or an armed GLB item
// in build mode. A merely-armed build tool with no ghost is NOT placing —
// otherwise the patch would show while the user isn't positioning anything.
const ed = useEditor.getState()
const scopeKind = useInteractionScope.getState().scope.kind
const placingOrMoving =
getMovingNode() != null ||
scopeKind === 'drafting' ||
scopeKind === 'placing' ||
(ed.mode === 'build' && ed.selectedItem != null)
const gridSnap = isGridSnapActive()
// The grid is a placement aid, not always-on chrome: it shows ONLY while
// actively placing/moving in grid-snap mode, as a tight cursor patch. Idle,
// select, and non-grid placement all hide it entirely.
const snapPatchVisible = placingOrMoving && gridSnap
revealRadiusUniform.value = PLACEMENT_REVEAL_RADIUS
baseAlphaUniform.value = 0
cellSizeUniform.value = useEditor.getState().gridSnapStep
patchAlphaUniform.value = 1.5
gridRef.current.visible = useViewer.getState().showGrid && snapPatchVisible
})
// Pass the geometry as a prop instead of a JSX child so the mesh // Pass the geometry as a prop instead of a JSX child so the mesh
// is never reconciled with R3F's empty placeholder `BufferGeometry`. // is never reconciled with R3F's empty placeholder `BufferGeometry`.
@@ -183,13 +259,8 @@ export const Grid = ({
useEffect(() => () => geometry.dispose(), [geometry]) useEffect(() => () => geometry.dispose(), [geometry])
return ( return (
<mesh // Orientation is driven imperatively in `useFrame` (horizontal by default,
geometry={geometry} // tilted into the wall plane while placing on a wall), so no static rotation.
layers={GRID_LAYER} <mesh geometry={geometry} layers={GRID_LAYER} material={material} ref={gridRef} />
material={material}
ref={gridRef}
rotation-x={-Math.PI / 2}
visible={showGrid}
/>
) )
} }
+123 -9
View File
@@ -2,6 +2,9 @@
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { import {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getSceneMaterialIdFromRef,
initSpaceDetectionSync, initSpaceDetectionSync,
initSpatialGridSync, initSpatialGridSync,
spatialGridManager, spatialGridManager,
@@ -19,6 +22,7 @@ import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard' import { useKeyboard } from '../../hooks/use-keyboard'
import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint'
import { import {
applySceneGraphToEditor, applySceneGraphToEditor,
loadSceneFromLocalStorage, loadSceneFromLocalStorage,
@@ -81,6 +85,7 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const SCENE_READY_FALLBACK_MS = 8000 const SCENE_READY_FALLBACK_MS = 8000
type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked'
const EDITOR_HOVER_STYLES: HoverStyles = { const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true }, default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false }, delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
@@ -535,14 +540,70 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
) )
} }
function getActivePaintMaterialSwatchColor(
material: ActivePaintMaterial | null,
sceneMaterials: ReturnType<typeof useScene.getState>['materials'],
) {
const directColor = material?.material?.properties?.color
if (directColor) return directColor
const sceneMaterialId = getSceneMaterialIdFromRef(material?.materialPreset)
if (sceneMaterialId) {
const sceneMaterial = sceneMaterials[sceneMaterialId as keyof typeof sceneMaterials]
const sceneColor = sceneMaterial?.material.properties?.color
if (sceneColor) return sceneColor
}
const catalogId =
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
const catalogMaterial = getCatalogMaterialById(catalogId)
return (
catalogMaterial?.previewColor ??
catalogMaterial?.preset.mapProperties.color ??
PAINT_CURSOR_BADGE_COLOR
)
}
function getActivePaintMaterialSwatchImageUrl(
material: ActivePaintMaterial | null,
sceneMaterials: ReturnType<typeof useScene.getState>['materials'],
) {
const directTextureUrl = material?.material?.texture?.url
if (directTextureUrl) return directTextureUrl
const sceneMaterialId = getSceneMaterialIdFromRef(material?.materialPreset)
if (sceneMaterialId) {
const sceneMaterial = sceneMaterials[sceneMaterialId as keyof typeof sceneMaterials]
const sceneTextureUrl = sceneMaterial?.material.texture?.url
if (sceneTextureUrl) return sceneTextureUrl
}
const catalogId =
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
const catalogMaterial = getCatalogMaterialById(catalogId)
return catalogMaterial?.previewThumbnailUrl ?? catalogMaterial?.preset.maps.albedoMap
}
function PaintCursorBadge({ function PaintCursorBadge({
position, position,
disabled, state,
swatchColor,
swatchImageUrl,
isEraser,
}: { }: {
position: { x: number; y: number } position: { x: number; y: number }
disabled: boolean state: PaintCursorBadgeState
swatchColor: string
swatchImageUrl?: string
isEraser: boolean
}) { }) {
const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR const accentColor =
state === 'ready'
? isEraser
? PAINT_CURSOR_BADGE_COLOR
: swatchColor
: PAINT_CURSOR_BADGE_DISABLED_COLOR
const iconOpacity = state === 'ready' ? 1 : state === 'blocked' ? 0.62 : 0.42
const lineHeight = 18 const lineHeight = 18
return ( return (
@@ -576,7 +637,48 @@ function PaintCursorBadge({
aria-hidden="true" aria-hidden="true"
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]" className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
src="/icons/paint.webp" src="/icons/paint.webp"
style={{
filter: state === 'ready' ? undefined : 'grayscale(1)',
opacity: iconOpacity,
}}
/> />
{state === 'ready' ? (
isEraser ? (
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/35 bg-zinc-950 text-white shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
<Icon
aria-hidden="true"
color="currentColor"
height={10}
icon="mdi:eraser-variant"
width={10}
/>
</span>
) : (
<span
className="-right-1 -bottom-1 absolute h-3.5 w-3.5 rounded-full border border-white/70 bg-cover bg-center shadow-[0_2px_6px_rgba(0,0,0,0.45)]"
style={{
backgroundColor: swatchColor,
backgroundImage: swatchImageUrl
? `url(${JSON.stringify(swatchImageUrl)})`
: undefined,
}}
/>
)
) : state === 'blocked' ? (
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/30 bg-zinc-950 text-rose-300 shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
<Icon
aria-hidden="true"
color="currentColor"
height={12}
icon="mdi:cancel"
width={12}
/>
</span>
) : (
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/30 bg-zinc-950 font-semibold text-[9px] text-slate-300 shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
?
</span>
)}
</div> </div>
</div> </div>
) )
@@ -730,6 +832,9 @@ function PaintCursorLayer({
}) { }) {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial) const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
const paintEraser = useEditor((s) => s.paintEraser)
const paintHover = useEditor((s) => s.paintHover)
const sceneMaterials = useScene((s) => s.materials)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null) const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const active = mode === 'material-paint' && !isVersionPreviewMode const active = mode === 'material-paint' && !isVersionPreviewMode
@@ -779,11 +884,14 @@ function PaintCursorLayer({
} }
}, [active, containerRef]) }, [active, containerRef])
const hasMaterial = Boolean( const hasPaint = paintEraser || hasActivePaintMaterial(activePaintMaterial)
activePaintMaterial && const badgeState: PaintCursorBadgeState = !hasPaint
(activePaintMaterial.material !== undefined || ? 'empty'
activePaintMaterial.materialPreset !== undefined), : paintHover != null
) ? 'ready'
: 'blocked'
const swatchColor = getActivePaintMaterialSwatchColor(activePaintMaterial, sceneMaterials)
const swatchImageUrl = getActivePaintMaterialSwatchImageUrl(activePaintMaterial, sceneMaterials)
if (!active || !position) return null if (!active || !position) return null
@@ -792,7 +900,13 @@ function PaintCursorLayer({
className="pointer-events-none absolute z-40" className="pointer-events-none absolute z-40"
style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }} style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }}
> >
<PaintCursorBadge disabled={!hasMaterial} position={{ x: 0, y: 0 }} /> <PaintCursorBadge
isEraser={paintEraser}
position={{ x: 0, y: 0 }}
state={badgeState}
swatchColor={swatchColor}
swatchImageUrl={swatchImageUrl}
/>
</div> </div>
) )
} }
@@ -1,10 +1,11 @@
'use client' 'use client'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { Copy, Move, Spline, Trash2 } from 'lucide-react' import { Copy, Move, Search, Spline, Trash2 } from 'lucide-react'
import type { MouseEventHandler, PointerEventHandler } from 'react' import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = { type NodeActionMenuProps = {
onFind?: MouseEventHandler<HTMLButtonElement>
onAddHole?: MouseEventHandler<HTMLButtonElement> onAddHole?: MouseEventHandler<HTMLButtonElement>
onDelete?: MouseEventHandler<HTMLButtonElement> onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement> onDuplicate?: MouseEventHandler<HTMLButtonElement>
@@ -17,6 +18,7 @@ type NodeActionMenuProps = {
} }
export function NodeActionMenu({ export function NodeActionMenu({
onFind,
onAddHole, onAddHole,
onDelete, onDelete,
onDuplicate, onDuplicate,
@@ -35,6 +37,17 @@ export function NodeActionMenu({
onPointerLeave={onPointerLeave} onPointerLeave={onPointerLeave}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
> >
{onFind && (
<button
aria-label="Find in catalog"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onFind}
title="Find in catalog"
type="button"
>
<Search className="h-4 w-4" />
</button>
)}
{onMove && ( {onMove && (
<button <button
aria-label="Move" aria-label="Move"
@@ -15,7 +15,6 @@ import {
sceneRegistry, sceneRegistry,
snapScalar, snapScalar,
type TapActionHandle, type TapActionHandle,
type TranslateHandle,
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -45,7 +44,6 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help' import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
import { createEditorApi } from '../../lib/editor-api' import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import useInteractionScope, { import useInteractionScope, {
@@ -54,7 +52,6 @@ import useInteractionScope, {
useMovingNode, useMovingNode,
} from '../../store/use-interaction-scope' } from '../../store/use-interaction-scope'
import useOpeningGuides from '../../store/use-opening-guides' import useOpeningGuides from '../../store/use-opening-guides'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { formatAngleRadians } from '../tools/shared/segment-angle' import { formatAngleRadians } from '../tools/shared/segment-angle'
import { import {
ARROW_COLOR, ARROW_COLOR,
@@ -209,9 +206,15 @@ export function NodeArrowHandles() {
const def = node ? nodeRegistry.get(node.type) : null const def = node ? nodeRegistry.get(node.type) : null
const descriptors = useMemo(() => { const descriptors = useMemo(() => {
if (!(node && def?.handles)) return null if (!(node && def?.handles)) return null
return typeof def.handles === 'function' const all =
typeof def.handles === 'function'
? def.handles(node as never) ? def.handles(node as never)
: (def.handles as HandleDescriptor[]) : (def.handles as HandleDescriptor[])
// The whole-node move-cross gizmo is gone: moving is now click-to-move on
// the selected node body (see selection-manager). Drop both flavours — the
// `translate` ground cross (column/roof/shelf/spawn) and the `tap-action`
// `move-cross` (item/door/window/elevator/stair) — keep rotate/resize.
return all.filter((d) => d.kind !== 'translate' && !('shape' in d && d.shape === 'move-cross'))
}, [node, def]) }, [node, def])
const shouldRender = const shouldRender =
@@ -523,17 +526,6 @@ function ArrowHandle({
/> />
) )
} }
if (descriptor.kind === 'translate') {
return (
<TranslateArrow
descriptor={descriptor}
dragControls={dragControls}
handleIndex={handleIndex}
node={placementNode}
rideObject={rideObject}
/>
)
}
if (descriptor.kind === 'tap-action') { if (descriptor.kind === 'tap-action') {
// Tap-action handles (fence side-move arrows, corner pickers) aren't // Tap-action handles (fence side-move arrows, corner pickers) aren't
// resize handles, so the freeze-at-pre-drag mechanism — which only // resize handles, so the freeze-at-pre-drag mechanism — which only
@@ -1226,65 +1218,6 @@ function ArcArrow({
) )
} }
// Free ground-plane move gizmo (the 4-way cross). Press-drag-release: raycast
// the horizontal plane at the node's base, convert the hit into the node's
// parent-local frame, add the delta to the node's drag-start position, grid-
// snap via the descriptor's `snapExtents`, and publish to `useLiveNodeOverrides`
// each move — committing one write to the store on release. The override stays
// at base Y; `<FloorElevationSystem>` reads that effective node and owns the
// presentation-only slab lift so the handle path shares the menu-move stacking
// contract without storing lifted positions.
function TranslateArrow({
descriptor,
node,
}: {
descriptor: TranslateHandle<AnyNode>
node: AnyNode
handleIndex: number
dragControls: HandleDragControls
rideObject: Object3D
}) {
const [isHovered, setIsHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom * ARROW_SCALE
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
const position = descriptor.placement.position(node, placementSceneApi)
const cursor: Cursor = 'move'
// 'node-normal' constrains the drag to the wall face (plane ⟂ the node's
// local +Z). Its cross icon stands up into that plane (tilt about X).
const isWallPlane = descriptor.plane === 'node-normal'
// Same function as the floating action menu's Move button
// (`floating-action-menu.tsx` → `handleMove`): arm the registry move tool,
// which owns the cursor follow, grid + alignment snap, green guide overlay,
// and click-to-commit. Routes both entry points through one path so the
// 3D translate gizmo and the floating Move button behave identically.
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(node as never)
useViewer.getState().setSelection({ selectedIds: [] })
}
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
// it lies in the item-local XY plane (= the wall face).
const iconRotation: [number, number, number] = isWallPlane ? NODE_NORMAL_TILT : [0, 0, 0]
return (
<HandleArrow
cursor={cursor}
hover={isHovered}
onHoverChange={setIsHovered}
onPointerDown={activate}
placement={{ position, rotation: iconRotation, baseScale }}
shape="cross"
/>
)
}
// Click-to-engage affordance — no drag plumbing, just a click target. The // Click-to-engage affordance — no drag plumbing, just a click target. The
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage // descriptor's `onActivate` receives sceneApi + editorApi so it can engage
// move tools, endpoint drags, or any other editor-state transition without // move tools, endpoint drags, or any other editor-state transition without
@@ -32,6 +32,7 @@ import {
getRoofMaterialArray, getRoofMaterialArray,
useViewer, useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three' import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
import { import {
@@ -701,6 +702,10 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
export const SelectionManager = () => { export const SelectionManager = () => {
const phase = useEditor((s) => s.phase) const phase = useEditor((s) => s.phase)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
// The canvas element — cursor styling must land here, not on `document.body`:
// the editor wraps the canvas in a div with a custom `cursor: url(...)`, which
// (being a closer ancestor) overrides any body cursor over the canvas.
const glDomElement = useThree((s) => s.gl.domElement)
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode) const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
const modifierKeysRef = useRef<SelectionModifierKeys>({ const modifierKeysRef = useRef<SelectionModifierKeys>({
meta: false, meta: false,
@@ -1252,6 +1257,52 @@ export const SelectionManager = () => {
} }
}, [isCurveReshape, mode, movingNode]) }, [isCurveReshape, mode, movingNode])
// Move cursor over the selected movable node: the visual cue that clicking it
// picks it up (replaces the removed move-cross gizmo). Reacts only when the
// hovered/selected node changes (not on every camera move) so it doesn't fight
// the rotate/resize gizmos' own hover cursors. Clears only the cursor it owns.
useEffect(() => {
if (mode !== 'select') return
let owns = false
let prevKey = ''
const applyCursor = () => {
const { selection, hoveredId } = useViewer.getState()
const sole = selection.selectedIds.length === 1 ? selection.selectedIds[0] : null
const key = `${hoveredId ?? ''}|${sole ?? ''}`
if (key === prevKey) return
prevKey = key
const node =
sole != null && hoveredId === sole && !getMovingNode()
? useScene.getState().nodes[sole as AnyNodeId]
: null
if (node && canDirectMoveNode(node)) {
glDomElement.style.cursor = 'move'
owns = true
} else if (owns) {
glDomElement.style.cursor = ''
owns = false
}
}
applyCursor()
const unsub = useViewer.subscribe(applyCursor)
return () => {
unsub()
if (owns) glDomElement.style.cursor = ''
}
}, [mode, glDomElement])
// While a node is actively being moved (click-to-move / Move button, or a
// fresh preset placement), show a grabbing hand. Mode-independent: presets
// move in build mode. Overrides the hover 'move' cursor (which bails while a
// movingNode exists), and clears back to the canvas's custom cursor on drop.
useEffect(() => {
if (!movingNode) return
glDomElement.style.cursor = 'grabbing'
return () => {
glDomElement.style.cursor = ''
}
}, [movingNode, glDomElement])
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || isCurveReshape) return if (movingNode || isCurveReshape) return
@@ -1439,6 +1490,23 @@ export const SelectionManager = () => {
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole') .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
} }
// Click-to-move: clicking the already-selected sole movable node with
// no modifiers picks it up instead of re-selecting — the move-cross
// gizmo's old job, now on the node body. `setMovingNode` arms the
// registry move tool in click-to-commit mode, exactly like the floating
// Move button. The first (selecting) click can't hit this because the
// node isn't yet in `selectedIdsBeforeRouting`.
const nativeEvent = event.nativeEvent
const hasModifier = nativeEvent.shiftKey || isCommandModifier(nativeEvent)
const isAlreadySole =
selectedIdsBeforeRouting.length === 1 && selectedIdsBeforeRouting[0] === nodeToSelect.id
if (!hasModifier && isAlreadySole && !getMovingNode() && canDirectMoveNode(nodeToSelect)) {
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(nodeToSelect as never)
useViewer.getState().setSelection({ selectedIds: [] })
return
}
activeStrategy.handleSelect( activeStrategy.handleSelect(
nodeToSelect, nodeToSelect,
event.nativeEvent, event.nativeEvent,
@@ -245,7 +245,7 @@ export function useFloorplanBackgroundPlacement({
// Single mode commits one segment per click: the same emit above // Single mode commits one segment per click: the same emit above
// already made the 3D fence tool stopDrafting, so close the 2D // already made the 3D fence tool stopDrafting, so close the 2D
// draft too instead of chaining. // draft too instead of chaining.
if (useEditor.getState().fenceChainMode === 'single') { if (useEditor.getState().getContinuation('fence') === 'single') {
clearFencePlacementDraft() clearFencePlacementDraft()
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
return true return true
@@ -27,7 +27,10 @@ import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
Box3, Box3,
BufferGeometry,
DoubleSide,
Euler, Euler,
Float32BufferAttribute,
type Group, type Group,
type LineSegments, type LineSegments,
Matrix4, Matrix4,
@@ -40,6 +43,10 @@ import {
} from 'three' } from 'three'
import { distance, smoothstep, uv, vec2 } from 'three/tsl' import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import {
clearPlacementSurface,
publishPlacementSurface,
} from '../../../lib/active-placement-surface'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { formatLinearMeasurement } from '../../../lib/measurements' import { formatLinearMeasurement } from '../../../lib/measurements'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -185,6 +192,45 @@ const dist = distance(uv(), center)
const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6) const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
basePlaneMaterial.opacityNode = radialOpacity basePlaneMaterial.opacityNode = radialOpacity
// Facing indicator: a small flat triangle on the floor in front of the ghost,
// pointing along the item's forward (-Z) direction. The cursor group already
// applies the item rotation, so the triangle stays in the group's local frame.
// Pushed clear of the measurement label pill (which sits ~0.24 off the same
// edge) and sized up so it stays legible at shallow camera angles.
const FACING_INDICATOR_WIDTH = 0.4
const FACING_INDICATOR_LENGTH = 0.46
const FACING_INDICATOR_GAP = 0.45
const facingIndicatorGeometry = (() => {
const geometry = new BufferGeometry()
// Tip at local +Z (the item's forward face); base across the X axis.
geometry.setAttribute(
'position',
new Float32BufferAttribute(
[
0,
0,
FACING_INDICATOR_LENGTH,
FACING_INDICATOR_WIDTH / 2,
0,
0,
-FACING_INDICATOR_WIDTH / 2,
0,
0,
],
3,
),
)
return geometry
})()
const facingIndicatorMaterial = new MeshBasicNodeMaterial({
color: 0x22_c5_5e, // green-500 (forward)
depthTest: false,
depthWrite: false,
// The flat triangle is viewed from above; DoubleSide makes it visible
// regardless of vertex winding (and from a camera orbited below the floor).
side: DoubleSide,
})
export interface PlacementCoordinatorConfig { export interface PlacementCoordinatorConfig {
asset: AssetInput | null asset: AssetInput | null
draftNode: DraftNodeHandle draftNode: DraftNodeHandle
@@ -451,6 +497,30 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
: validators : validators
const finishCommittedPlacement = (
committedId: string | null,
wasAdopted: boolean,
repeat: () => void,
) => {
if (configRef.current.onCommitted()) {
repeat()
return
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
if (committedId) {
useViewer.getState().setSelection({ selectedIds: [committedId as AnyNodeId] })
}
if (!wasAdopted) {
useEditor.getState().setTool(null)
}
// A non-repeating placement is finished: return to select mode so the user
// lands on the just-placed (now selected) node instead of a tool-less build
// limbo. Repeat placements took the early return above and stay armed.
useEditor.getState().setMode('select')
}
const revalidate = (): boolean => { const revalidate = (): boolean => {
const placeable = altFreeRef.current || checkCanPlace(getContext(), validators) const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500 const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
@@ -869,8 +939,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
if (configRef.current.onCommitted()) { const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
draftNode.create( draftNode.create(
gridPosition.current, gridPosition.current,
asset, asset,
@@ -886,7 +958,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
updatePreviewGeometry(previewBounds) updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds) updateDimensionGuides(previewBounds)
revalidate() revalidate()
} })
} }
// ---- Wall Handlers ---- // ---- Wall Handlers ----
@@ -1065,12 +1137,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (result.dirtyNodeId) { if (result.dirtyNodeId) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId) useScene.getState().dirtyNodes.add(result.dirtyNodeId)
} }
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter( const enterResult = wallStrategy.enter(
getContext(), getContext(),
@@ -1084,7 +1158,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else { } else {
revalidate() revalidate()
} }
} })
} }
const onWallLeave = (event: WallEvent) => { const onWallLeave = (event: WallEvent) => {
@@ -1222,16 +1296,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current) const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
revalidate() revalidate()
} }
} })
} }
const onRoofWallLeave = (event: RoofEvent) => { const onRoofWallLeave = (event: RoofEvent) => {
@@ -1431,15 +1507,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
if (configRef.current.onCommitted()) { const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never) const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
revalidate() revalidate()
} }
} })
return return
} }
} }
@@ -1457,15 +1535,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
if (configRef.current.onCommitted()) { const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic) const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
revalidate() revalidate()
} }
} })
return return
} }
} }
@@ -1486,8 +1566,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
if (configRef.current.onCommitted()) { const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter( const enterResult = ceilingStrategy.enter(
getContext(), getContext(),
@@ -1500,7 +1582,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else { } else {
revalidate() revalidate()
} }
} })
return return
} }
} }
@@ -1516,9 +1598,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
// Try to set up next draft on the same surface // Try to set up next draft on the same surface
const enterResult = itemSurfaceStrategy.enter(getContext(), event) const enterResult = itemSurfaceStrategy.enter(getContext(), event)
if (enterResult) { if (enterResult) {
@@ -1526,7 +1610,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else { } else {
revalidate() revalidate()
} }
} })
} }
// ---- Ceiling Handlers ---- // ---- Ceiling Handlers ----
@@ -1642,9 +1726,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (enterResult) { if (enterResult) {
@@ -1652,7 +1738,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else { } else {
revalidate() revalidate()
} }
} })
} }
const onCeilingLeave = (event: CeilingEvent) => { const onCeilingLeave = (event: CeilingEvent) => {
@@ -1780,16 +1866,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = shelfSurfaceStrategy.enter(getContext(), event) const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
revalidate() revalidate()
} }
} })
} }
// ---- Keyboard rotation ---- // ---- Keyboard rotation ----
@@ -2141,6 +2229,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Restore the draft mesh's raycast when the coordinator unmounts (tool change). // Restore the draft mesh's raycast when the coordinator unmounts (tool change).
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast]) useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
// Publish the ghost's surface (contact point + normal) so the grid's snap
// patch sits at the item's resolved height (e.g. a shelf top) and orients to
// the surface (vertical in a wall plane). Only this coordinator publishes — a
// moving existing node has no draft here, so the grid reads that case straight
// off the node's mesh. Cleared when idle.
const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
useFrame(() => {
const ghost = cursorGroupRef.current
if (asset && ghost) {
const surf = placementState.current.surface
const n = surfaceNormalRef.current
if (surf === 'wall' || surf === 'roof-wall') {
// Wall-attached: the item's forward (+Z) faces out of the wall, so the
// item's outward face direction IS the wall normal.
n.set(0, 0, 1).applyQuaternion(ghost.quaternion)
} else {
n.set(0, 1, 0)
}
publishPlacementSurface(ghost.position, n)
} else {
clearPlacementSurface()
}
})
useEffect(() => () => clearPlacementSurface(), [])
useFrame(() => { useFrame(() => {
if (!asset) { if (!asset) {
reconcileDraftRaycast(null) reconcileDraftRaycast(null)
@@ -2245,6 +2358,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
currentDimensionBounds.dimensions[1] / 2, currentDimensionBounds.dimensions[1] / 2,
currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2, currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
] ]
const facingIndicatorPosition: [number, number, number] = [
currentDimensionBounds.center[0],
0.02,
currentDimensionBounds.center[2] +
currentDimensionBounds.dimensions[2] / 2 +
FACING_INDICATOR_GAP,
]
const measurementContent = ( const measurementContent = (
<> <>
@@ -2346,6 +2466,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
ref={basePlaneRef} ref={basePlaneRef}
renderOrder={999} renderOrder={999}
/> />
<mesh
geometry={facingIndicatorGeometry}
layers={EDITOR_LAYER}
material={facingIndicatorMaterial}
position={facingIndicatorPosition}
renderOrder={1000}
/>
</group> </group>
) )
} }
@@ -16,6 +16,7 @@ import {
type PortConnectivity, type PortConnectivity,
resolveAlignment, resolveAlignment,
resolveConnectivityUpdates, resolveConnectivityUpdates,
resolveFacingIndicator,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveNodeOverrides, useLiveNodeOverrides,
@@ -32,6 +33,7 @@ import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placemen
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveSnapFlags } from '../../../lib/snapping-mode' import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor' import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { swallowNextClick } from '../../editor/node-arrow-handles' import { swallowNextClick } from '../../editor/node-arrow-handles'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { DragBoundingBox } from '../shared/drag-bounding-box' import { DragBoundingBox } from '../shared/drag-bounding-box'
@@ -773,6 +775,23 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
[node], [node],
) )
// Forward-facing triangle for the footprint-box branch (item / shelf / column
// — anything that renders `<PlacementBox>`). Published to the editor-side
// overlay; the `<DragBoundingBox>` branch (e.g. stair, which has no centred
// footprint) publishes its own. The box is centred on `cursorPosition`, so
// the footprint centre is the origin. Clears on unmount.
const facing = resolveFacingIndicator(node.type)
useEffect(() => {
if (!previewVisible || !facing || !boxDimensions) return
useFacingPose.getState().set({
position: cursorPosition,
rotationY: cursorRotationY,
depth: boxDimensions[2],
reversed: facing.reversed,
})
}, [previewVisible, facing, boxDimensions, cursorPosition, cursorRotationY])
useEffect(() => () => useFacingPose.getState().clear(), [])
if (!previewVisible) return null if (!previewVisible) return null
if (boxDimensions) { if (boxDimensions) {
@@ -1,6 +1,6 @@
'use client' 'use client'
import { sceneRegistry } from '@pascal-app/core' import { type AnyNodeId, resolveFacingIndicator, sceneRegistry, useScene } from '@pascal-app/core'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { import {
Box3, Box3,
@@ -15,6 +15,7 @@ import {
import { distance, smoothstep, uv, vec2 } from 'three/tsl' import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import useFacingPose from '../../../store/use-facing-pose'
const NO_RAYCAST = () => null const NO_RAYCAST = () => null
@@ -93,6 +94,9 @@ export function DragBoundingBox({
centerY, centerY,
color = DEFAULT_COLOR, color = DEFAULT_COLOR,
}: DragBoundingBoxProps) { }: DragBoundingBoxProps) {
const nodeType = useScene((state) => state.nodes[nodeId as AnyNodeId]?.type)
const facing = nodeType ? resolveFacingIndicator(nodeType) : null
const measured = useMemo(() => { const measured = useMemo(() => {
if (size) return null if (size) return null
const obj = sceneRegistry.nodes.get(nodeId) const obj = sceneRegistry.nodes.get(nodeId)
@@ -104,6 +108,7 @@ export function DragBoundingBox({
? [0, centerY ?? size[1] / 2, 0] ? [0, centerY ?? size[1] / 2, 0]
: (measured?.center ?? [0, fallbackSize[1] / 2, 0]) : (measured?.center ?? [0, fallbackSize[1] / 2, 0])
const minY = cy - h / 2 const minY = cy - h / 2
const groundY = minY + 0.01
const edgeGeometry = useMemo(() => { const edgeGeometry = useMemo(() => {
const box = new BoxGeometry(w, h, d) const box = new BoxGeometry(w, h, d)
@@ -117,9 +122,9 @@ export function DragBoundingBox({
const planeGeometry = useMemo(() => { const planeGeometry = useMemo(() => {
const plane = new PlaneGeometry(w, d) const plane = new PlaneGeometry(w, d)
plane.rotateX(-Math.PI / 2) plane.rotateX(-Math.PI / 2)
plane.translate(cx, minY + 0.01, cz) plane.translate(cx, groundY, cz)
return plane return plane
}, [w, d, cx, minY, cz]) }, [w, d, cx, groundY, cz])
const edgeMaterial = useMemo( const edgeMaterial = useMemo(
() => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }), () => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }),
@@ -147,6 +152,22 @@ export function DragBoundingBox({
[edgeGeometry, planeGeometry, edgeMaterial, planeMaterial], [edgeGeometry, planeGeometry, edgeMaterial, planeMaterial],
) )
// Publish the facing pose to the editor-side overlay (the single triangle
// renderer) rather than drawing it here. The node origin is `position`; the
// footprint centre is `[cx, cz]` in the node's local frame. Runs each drag
// frame so the triangle follows; a separate mount/unmount effect clears it.
useEffect(() => {
if (!facing || d <= 0) return
useFacingPose.getState().set({
position: [position[0], position[1] + groundY, position[2]],
rotationY,
depth: d,
center: [cx, cz],
reversed: facing.reversed,
})
}, [facing, position, rotationY, d, cx, cz, groundY])
useEffect(() => () => useFacingPose.getState().clear(), [])
if (w <= 0 || h <= 0 || d <= 0) return null if (w <= 0 || h <= 0 || d <= 0) return null
return ( return (
@@ -0,0 +1,87 @@
import { useEffect, useMemo } from 'react'
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
// A flat forward-pointing triangle drawn on the floor just in front of a
// placement ghost, so the direction the node will face is obvious. Tip at local
// +Z (every kind's forward face); render it inside the ghost's rotated group so
// it inherits the node's yaw. Matches the item coordinator / drag-bounding-box
// indicators (same size, colour, double-sided so it shows from above).
const FACING_INDICATOR_WIDTH = 0.4
const FACING_INDICATOR_LENGTH = 0.46
const FACING_INDICATOR_GAP = 0.45
/**
* @param depth bbox depth (along local Z) of the ghost — positions the
* triangle just past the front edge.
* @param center optional [x, z] of the bbox centre in the ghost's local frame.
* @param reversed point along local -Z (the front is the -Z side, e.g. a stair
* entry) instead of +Z.
* @param y small lift off the floor to avoid z-fighting.
*/
export function FacingIndicator({
depth,
center = [0, 0],
reversed = false,
y = 0.02,
}: {
depth: number
center?: [number, number]
reversed?: boolean
y?: number
}) {
const dir = reversed ? -1 : 1
// Per-instance geometry/material (not module singletons) so this works no
// matter which package mounts it (the tools live in `nodes`, imported via
// `@pascal-app/editor`). Disposed on unmount.
const geometry = useMemo(() => {
const g = new BufferGeometry()
g.setAttribute(
'position',
new Float32BufferAttribute(
[
0,
0,
dir * FACING_INDICATOR_LENGTH,
FACING_INDICATOR_WIDTH / 2,
0,
0,
-FACING_INDICATOR_WIDTH / 2,
0,
0,
],
3,
),
)
return g
}, [dir])
const material = useMemo(
() =>
new MeshBasicNodeMaterial({
color: 0x22_c5_5e, // green-500 (forward)
depthTest: false,
depthWrite: false,
side: DoubleSide,
}),
[],
)
useEffect(
() => () => {
geometry.dispose()
material.dispose()
},
[geometry, material],
)
return (
<mesh
frustumCulled={false}
geometry={geometry}
layers={EDITOR_LAYER}
material={material}
position={[center[0], y, center[1] + dir * (depth / 2 + FACING_INDICATOR_GAP)]}
renderOrder={1001}
/>
)
}
@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import useFacingPose, { type FacingPose } from '../../../store/use-facing-pose'
import { FacingIndicator } from './facing-indicator'
// The single editor-side renderer for the placement/move facing triangle.
// Mounted once inside ToolManager's building-local group; every tool publishes
// its ghost pose to `useFacingPose` and this draws the triangle. The pose
// (position/yaw) is applied imperatively to a ref so the per-frame cursor
// updates don't re-render React — only a change in footprint shape (depth /
// centre), which is constant per tool session, triggers a re-render.
export function FacingPoseIndicator() {
const groupRef = useRef<Group>(null)
const [shape, setShape] = useState<Pick<FacingPose, 'depth' | 'center' | 'reversed'> | null>(null)
useEffect(() => {
const apply = (pose: FacingPose | null) => {
const group = groupRef.current
if (group) {
if (pose) {
group.visible = true
group.position.set(...pose.position)
group.rotation.y = pose.rotationY
} else {
group.visible = false
}
}
setShape((prev) => {
if (!pose) return null
const center = pose.center ?? [0, 0]
if (
prev &&
prev.depth === pose.depth &&
prev.reversed === pose.reversed &&
(prev.center ?? [0, 0])[0] === center[0] &&
(prev.center ?? [0, 0])[1] === center[1]
) {
return prev
}
return { depth: pose.depth, center, reversed: pose.reversed }
})
}
apply(useFacingPose.getState().pose)
return useFacingPose.subscribe((state) => apply(state.pose))
}, [])
return (
<group ref={groupRef} visible={false}>
{shape ? (
<FacingIndicator center={shape.center} depth={shape.depth} reversed={shape.reversed} />
) : null}
</group>
)
}
@@ -24,6 +24,7 @@ import {
resolveStairPlacementLevelId, resolveStairPlacementLevelId,
} from '../../../lib/stair-levels' } from '../../../lib/stair-levels'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
@@ -300,6 +301,19 @@ export const StairTool: React.FC = () => {
previewRef.current.rotation.y = rotation previewRef.current.rotation.y = rotation
} }
// Forward-facing triangle (editor-side overlay). The run ascends along
// local +Z from the entry at z≈0; the stair's front is the -Z entry side,
// so `reversed` points the triangle out of the entry (where you approach
// from), sitting just before it — not inside the footprint or at the
// elevated far end. Centre is the footprint mid-run (origin is the entry).
useFacingPose.getState().set({
position: visualPosition,
rotationY: rotation,
depth: DEFAULT_STAIR_LENGTH,
center: [0, DEFAULT_STAIR_LENGTH / 2],
reversed: true,
})
if (!preview) { if (!preview) {
openingPreview.clear() openingPreview.clear()
return return
@@ -425,8 +439,17 @@ export const StairTool: React.FC = () => {
// Commit cleared the opening preview, so force the next hover (even on the // Commit cleared the opening preview, so force the next hover (even on the
// same cell) to rebuild rather than dedupe against the just-placed key. // same cell) to rebuild rather than dedupe against the just-placed key.
lastPreviewKey = null lastPreviewKey = null
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
// Single by default; the C-toggle ('point' context, shared with every
// other placement tool) opts into placing more. On single, drop the tool
// and the facing triangle so we fall back to select after one stair.
if (useEditor.getState().getContinuation('point') === 'repeat') {
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
} else {
useFacingPose.getState().clear()
useEditor.getState().setTool(null)
}
} }
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -471,6 +494,7 @@ export const StairTool: React.FC = () => {
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
openingPreview.clear() openingPreview.clear()
useFacingPose.getState().clear()
} }
}, [currentLevelId]) }, [currentLevelId])
@@ -478,7 +502,9 @@ export const StairTool: React.FC = () => {
<group> <group>
<CursorSphere ref={cursorRef} /> <CursorSphere ref={cursorRef} />
{/* 3D ghost preview — position/rotation updated imperatively */} {/* 3D ghost preview — position/rotation updated imperatively. The
forward-facing triangle is drawn by the editor-side overlay from the
pose published in `applyDraftPreview`. */}
<group ref={previewRef}> <group ref={previewRef}>
<mesh castShadow geometry={previewGeometry}> <mesh castShadow geometry={previewGeometry}>
<meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent /> <meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
@@ -25,6 +25,7 @@ import { ElevatorTool } from './elevator/elevator-tool'
import { MoveTool } from './item/move-tool' import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool' import { RoofTool } from './roof/roof-tool'
import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
import { FacingPoseIndicator } from './shared/facing-pose-indicator'
import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { StairTool } from './stair/stair-tool' import { StairTool } from './stair/stair-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -295,6 +296,10 @@ export const ToolManager: React.FC = () => {
tools above. Lives inside the building-local group so the tools above. Lives inside the building-local group so the
building-local guide coords render at the right world position. */} building-local guide coords render at the right world position. */}
<Alignment3DGuideLayer /> <Alignment3DGuideLayer />
{/* The one forward-facing triangle renderer. Placement/move tools
publish their ghost pose to `useFacingPose`; this draws it. Mounted
here so it shares the building-local frame the tools publish in. */}
<FacingPoseIndicator />
{/* Wall-plane proximity / sill / equal-spacing guides for openings, {/* Wall-plane proximity / sill / equal-spacing guides for openings,
published by the door/window move tools in the same world frame. */} published by the door/window move tools in the same world frame. */}
<OpeningGuides3DLayer /> <OpeningGuides3DLayer />
@@ -1,32 +1,19 @@
import { ShortcutToken } from '../primitives/shortcut-token' import { ContextualHelperPanel } from './contextual-helper-panel'
interface BuildingHelperProps { interface BuildingHelperProps {
showRotate?: boolean showRotate?: boolean
} }
// Rotate is one hint with both keys (R / T) — never two separate
// counterclockwise / clockwise rows — to match every other placement helper.
export function BuildingHelper({ showRotate }: BuildingHelperProps) { export function BuildingHelper({ showRotate }: BuildingHelperProps) {
return ( return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md"> <ContextualHelperPanel
<div className="flex items-center gap-2 text-sm"> hints={[
<ShortcutToken value="Left click" /> { keys: ['Left click'], label: 'Place building' },
<span className="text-muted-foreground">Place building</span> ...(showRotate ? [{ keys: ['R', 'T'], label: 'Rotate' }] : []),
</div> { keys: ['Esc'], label: 'Cancel' },
{showRotate && ( ]}
<> />
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="R" />
<span className="text-muted-foreground">Rotate counterclockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="T" />
<span className="text-muted-foreground">Rotate clockwise</span>
</div>
</>
)}
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
) )
} }
@@ -1,4 +1,9 @@
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { Fragment } from 'react'
import {
CONTINUATION_PROFILES,
type ContinuationContext,
} from '../../../lib/continuation'
import type { ContextualShortcutHint } from '../../../lib/contextual-help' import type { ContextualShortcutHint } from '../../../lib/contextual-help'
import { hasActivePaintMaterial } from '../../../lib/material-paint' import { hasActivePaintMaterial } from '../../../lib/material-paint'
import { paintScopeLabel, type PaintScope } from '../../../lib/paint-scope' import { paintScopeLabel, type PaintScope } from '../../../lib/paint-scope'
@@ -8,32 +13,101 @@ import {
type SnapContext, type SnapContext,
} from '../../../lib/snapping-mode' } from '../../../lib/snapping-mode'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor, { import useEditor, { type GridSnapStep } from '../../../store/use-editor'
type FenceChainMode,
type GridSnapStep,
type WallChainMode,
} from '../../../store/use-editor'
import { ShortcutToken } from '../primitives/shortcut-token' import { ShortcutToken } from '../primitives/shortcut-token'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
const PILL_CLASS = // One muted container holds every row — passive key hints and interactive chips
'flex items-center gap-3 rounded-full border border-border bg-popover/90 py-1.5 pr-1.5 pl-3.5 text-foreground text-[11px] shadow-md shadow-black/10 backdrop-blur-md' // alike — so the HUD reads as a single panel, not a stack of floating pills. The
// background is near-opaque (`bg-background/95`) with a single backdrop blur so
// active rows stay readable over the 3D scene even while a modifier is held.
// A 2-track grid: column 1 sizes to `max-content` (the widest key across ALL
// rows), column 2 (`1fr`) is the label. Every row is a subgrid sharing those
// tracks, so labels align even when keys differ in width (⌘ vs Shift) or wrap to
// two lines. Near-opaque bg + single backdrop blur keeps active rows readable.
const CONTAINER_CLASS =
'pointer-events-none fixed top-1/2 right-4 z-40 grid max-w-[260px] -translate-y-1/2 grid-cols-[max-content_1fr] gap-x-2.5 gap-y-1.5 rounded-lg border border-border bg-background/95 px-3 py-2.5 shadow-lg backdrop-blur-md'
const TOKEN_CLASS = 'h-5 px-1.5 text-[10px]'
// Each row spans both columns as its own subgrid, inheriting the container's
// tracks so its key/label cells land on the shared column lines.
const ROW_CLASS = 'col-span-2 grid grid-cols-subgrid'
// The key cell (column 1). `items-center` centres the token; the row's
// `items-start` keeps it on the label's first line when the label wraps.
const KEY_CELL_CLASS = 'flex items-center gap-1'
// Multiple keys in a contextual hint are alternatives (e.g. Rotate R / T), not a
// chord — the HUD never shows key chords — so they read on one line split by "/".
function ShortcutSequence({ keys }: { keys: string[] }) { function ShortcutSequence({ keys }: { keys: string[] }) {
return ( return (
<div className="flex shrink-0 items-center gap-1"> <div className={KEY_CELL_CLASS}>
{keys.map((key, index) => ( {keys.map((key, index) => (
<div className="flex items-center gap-1" key={`${key}-${index}`}> <Fragment key={`${key}-${index}`}>
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">/</span> : null} {index > 0 ? <span className="text-[9px] text-muted-foreground/70">/</span> : null}
<ShortcutToken className="h-6 px-1.5 text-[10px]" value={key} /> <ShortcutToken className={TOKEN_CLASS} value={key} />
</div> </Fragment>
))} ))}
</div> </div>
) )
} }
// Shared single-line chip row (key cell + icon/label cell). Rendered either as a
// passive row (no `onClick`) or a clickable button. The outer container is
// `pointer-events-none`, so clickable chips opt back in.
function ChipRow({
ariaLabel,
icon,
label,
onClick,
shortcut,
tooltip,
}: {
ariaLabel?: string
icon?: string
label: string
onClick?: () => void
shortcut?: string
tooltip?: string
}) {
const body = (
<>
<span className={KEY_CELL_CLASS}>
{shortcut ? <ShortcutToken className={TOKEN_CLASS} value={shortcut} /> : null}
</span>
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground text-xs">
{icon ? <Icon className="shrink-0" height={13} icon={icon} width={13} /> : null}
<span className="truncate">{label}</span>
</span>
</>
)
if (!onClick) {
return <div className={cn(ROW_CLASS, 'items-center')}>{body}</div>
}
const button = (
<button
aria-label={ariaLabel ?? label}
className={cn(
ROW_CLASS,
'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60',
)}
onClick={onClick}
type="button"
>
{body}
</button>
)
if (!tooltip) return button
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="left">{tooltip}</TooltipContent>
</Tooltip>
)
}
const SNAPPING_MODE_ICONS = { const SNAPPING_MODE_ICONS = {
grid: 'lucide:grid-2x2', grid: 'lucide:grid-2x2',
lines: 'lucide:magnet', lines: 'lucide:magnet',
@@ -48,26 +122,6 @@ const SNAPPING_MODE_LABELS = {
off: 'Off', off: 'Off',
} as const } as const
const WALL_CHAIN_MODE_ICONS: Record<WallChainMode, string> = {
room: 'lucide:square',
single: 'lucide:minus',
}
const WALL_CHAIN_MODE_LABELS: Record<WallChainMode, string> = {
room: 'Room (auto-close)',
single: 'Single wall',
}
const FENCE_CHAIN_MODE_ICONS: Record<FenceChainMode, string> = {
continuous: 'lucide:waypoints',
single: 'lucide:minus',
}
const FENCE_CHAIN_MODE_LABELS: Record<FenceChainMode, string> = {
continuous: 'Continuous',
single: 'Single fence',
}
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
function nextGridSnapStep(step: GridSnapStep): GridSnapStep { function nextGridSnapStep(step: GridSnapStep): GridSnapStep {
@@ -75,10 +129,8 @@ function nextGridSnapStep(step: GridSnapStep): GridSnapStep {
return GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]! return GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
} }
// Interactive chip rows: the active interaction's own snapping controls, scoped // The active interaction's snapping controls, scoped to its context (wall / item
// to its context (wall / item / polygon) so each action shows only the modes // / polygon) so each action shows only the modes that make sense for it.
// that make sense for it. The surrounding stack is `pointer-events-none` (passive
// key hints), so these pills carve out `pointer-events-auto` to stay clickable.
function SnappingChips({ context }: { context: SnapContext }) { function SnappingChips({ context }: { context: SnapContext }) {
const snappingMode = useEditor((s) => s.snappingModeByContext[context]) const snappingMode = useEditor((s) => s.snappingModeByContext[context])
const setSnappingMode = useEditor((s) => s.setSnappingMode) const setSnappingMode = useEditor((s) => s.setSnappingMode)
@@ -89,118 +141,43 @@ function SnappingChips({ context }: { context: SnapContext }) {
return ( return (
<> <>
<Tooltip> <ChipRow
<TooltipTrigger asChild> ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
<button
aria-label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
onClick={() => setSnappingMode(context, cycleSnappingModeIn(context, snappingMode))}
type="button"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
<Icon
className="shrink-0"
height={13}
icon={SNAPPING_MODE_ICONS[snappingMode]} icon={SNAPPING_MODE_ICONS[snappingMode]}
width={13} label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
onClick={() => setSnappingMode(context, cycleSnappingModeIn(context, snappingMode))}
shortcut="Shift"
tooltip="Snapping mode — click or press Shift to cycle"
/> />
<span className="truncate">Snapping: {SNAPPING_MODE_LABELS[snappingMode]}</span>
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Shift" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Snapping mode click or press Shift to cycle</TooltipContent>
</Tooltip>
{gridActive ? ( {gridActive ? (
<Tooltip> <ChipRow
<TooltipTrigger asChild> ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
<button label={`Grid: ${gridSnapStep.toFixed(2)} m`}
aria-label={`Grid step: ${gridSnapStep.toFixed(2)} m`}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
onClick={() => setGridSnapStep(nextGridSnapStep(gridSnapStep))} onClick={() => setGridSnapStep(nextGridSnapStep(gridSnapStep))}
type="button" shortcut="Ctrl"
> tooltip="Grid step — click or tap Ctrl to cycle"
<span className="min-w-0 flex-1 truncate font-medium"> />
Grid: <span className="tabular-nums">{gridSnapStep.toFixed(2)}</span> m
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Ctrl" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Grid step click or tap Ctrl to cycle</TooltipContent>
</Tooltip>
) : null} ) : null}
</> </>
) )
} }
function nextWallChainMode(mode: WallChainMode): WallChainMode { function ContinuationChip({ context }: { context: ContinuationContext }) {
return mode === 'room' ? 'single' : 'room' const mode = useEditor((s) => s.getContinuation(context))
} const cycleContinuation = useEditor((s) => s.cycleContinuation)
const profile = CONTINUATION_PROFILES[context]
function WallChainModeChip() { const label = profile.labels[mode] ?? mode
const wallChainMode = useEditor((s) => s.wallChainMode) const icon = profile.icons[mode] ?? 'lucide:repeat'
const setWallChainMode = useEditor((s) => s.setWallChainMode)
const label = WALL_CHAIN_MODE_LABELS[wallChainMode]
return ( return (
<Tooltip> <ChipRow
<TooltipTrigger asChild> ariaLabel={`Continuation: ${label}`}
<button icon={icon}
aria-label={`Wall drafting: ${label}`} label={label}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`} onClick={() => cycleContinuation(context)}
onClick={() => setWallChainMode(nextWallChainMode(wallChainMode))} shortcut="C"
type="button" tooltip="Continuation — click or press C to cycle"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
<Icon
className="shrink-0"
height={13}
icon={WALL_CHAIN_MODE_ICONS[wallChainMode]}
width={13}
/> />
<span className="truncate">{label}</span>
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Alt" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Wall drafting mode - click or tap Alt to cycle</TooltipContent>
</Tooltip>
)
}
function nextFenceChainMode(mode: FenceChainMode): FenceChainMode {
return mode === 'continuous' ? 'single' : 'continuous'
}
function FenceChainModeChip() {
const fenceChainMode = useEditor((s) => s.fenceChainMode)
const setFenceChainMode = useEditor((s) => s.setFenceChainMode)
const label = FENCE_CHAIN_MODE_LABELS[fenceChainMode]
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`Fence drafting: ${label}`}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
onClick={() => setFenceChainMode(nextFenceChainMode(fenceChainMode))}
type="button"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
<Icon
className="shrink-0"
height={13}
icon={FENCE_CHAIN_MODE_ICONS[fenceChainMode]}
width={13}
/>
<span className="truncate">{label}</span>
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Alt" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Fence drafting mode - click or tap Alt to cycle</TooltipContent>
</Tooltip>
) )
} }
@@ -213,7 +190,7 @@ const PAINT_SCOPE_ICONS: Record<PaintScope, string> = {
// The painter's application-scope chip. Driven entirely by the hovered node's // The painter's application-scope chip. Driven entirely by the hovered node's
// derived `paintHover` (scopes + labels), so it works for any kind without a // derived `paintHover` (scopes + labels), so it works for any kind without a
// per-target table. Carves out `pointer-events-auto` like the snapping chips. // per-target table.
function PaintScopeChip() { function PaintScopeChip() {
// What the cursor is over (that's what the next click paints). `null` when not // What the cursor is over (that's what the next click paints). `null` when not
// over a paintable surface — including an item with no slots. // over a paintable surface — including an item with no slots.
@@ -226,26 +203,13 @@ function PaintScopeChip() {
// Nothing to paint with yet (no material picked, not erasing) → the first step // Nothing to paint with yet (no material picked, not erasing) → the first step
// is choosing a material, so say that before anything about scope or hovering. // is choosing a material, so say that before anything about scope or hovering.
if (!(paintEraser || hasActivePaintMaterial(activePaintMaterial))) { if (!(paintEraser || hasActivePaintMaterial(activePaintMaterial))) {
return ( return <ChipRow icon="lucide:palette" label="Select a material to paint" />
<div className={PILL_CLASS}>
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium text-muted-foreground">
<Icon className="shrink-0" height={13} icon="lucide:palette" width={13} />
<span className="truncate">Select a material to paint</span>
</span>
</div>
)
} }
// Not over anything paintable → guide the user to hover, still teaching Shift. // Not over anything paintable → guide the user to hover, still teaching Shift.
if (!paintHover) { if (!paintHover) {
return ( return (
<div className={PILL_CLASS}> <ChipRow icon="lucide:mouse-pointer-click" label="Hover a surface to paint" shortcut="Shift" />
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium text-muted-foreground">
<Icon className="shrink-0" height={13} icon="lucide:mouse-pointer-click" width={13} />
<span className="truncate">Hover a surface to paint</span>
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Shift" />
</div>
) )
} }
@@ -255,36 +219,25 @@ function PaintScopeChip() {
const effective: PaintScope = scopes.includes(paintScope) ? paintScope : 'single' const effective: PaintScope = scopes.includes(paintScope) ? paintScope : 'single'
// Paintable but with no scope choice (roof, a one-slot node, …) → a passive // Paintable but with no scope choice (roof, a one-slot node, …) → a passive
// pill that still names the surface, so the user always sees what they'll paint. // row that still names the surface, so the user always sees what they'll paint.
if (scopes.length <= 1) { if (scopes.length <= 1) {
return ( return (
<div className={PILL_CLASS}> <ChipRow
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium"> icon={PAINT_SCOPE_ICONS[effective]}
<Icon className="shrink-0" height={13} icon={PAINT_SCOPE_ICONS[effective]} width={13} /> label={`Paint: ${paintScopeLabel(effective, paintHover)}`}
<span className="truncate">Paint: {paintScopeLabel(effective, paintHover)}</span> />
</span>
</div>
) )
} }
return ( return (
<Tooltip> <ChipRow
<TooltipTrigger asChild> ariaLabel={`Paint scope: ${paintScopeLabel(effective, paintHover)}`}
<button icon={PAINT_SCOPE_ICONS[effective]}
aria-label={`Paint scope: ${paintScopeLabel(effective, paintHover)}`} label={`Paint: ${paintScopeLabel(effective, paintHover)}`}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
onClick={() => cyclePaintScope()} onClick={() => cyclePaintScope()}
type="button" shortcut="Shift"
> tooltip="Paint scope — click or press Shift to cycle"
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium"> />
<Icon className="shrink-0" height={13} icon={PAINT_SCOPE_ICONS[effective]} width={13} />
<span className="truncate">Paint: {paintScopeLabel(effective, paintHover)}</span>
</span>
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Shift" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Paint scope click or press Shift to cycle</TooltipContent>
</Tooltip>
) )
} }
@@ -292,43 +245,44 @@ export function ContextualHelperPanel({
hints, hints,
snapContext = null, snapContext = null,
showPaintScope = false, showPaintScope = false,
showWallChainMode = false, continuationContext = null,
showFenceChainMode = false,
}: { }: {
hints: ContextualShortcutHint[] hints: ContextualShortcutHint[]
// The active snapping context drives the snapping chips (which mode set). Null // The active snapping context drives the snapping chips (which mode set). Null
// → no snapping chips for this interaction. // → no snapping chips for this interaction.
snapContext?: SnapContext | null snapContext?: SnapContext | null
showPaintScope?: boolean showPaintScope?: boolean
showWallChainMode?: boolean continuationContext?: ContinuationContext | null
showFenceChainMode?: boolean
}) { }) {
if ( if (hints.length === 0 && !snapContext && !showPaintScope && !continuationContext)
hints.length === 0 &&
!snapContext &&
!showPaintScope &&
!showWallChainMode &&
!showFenceChainMode
)
return null return null
return ( return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col items-end gap-2"> <div className={CONTAINER_CLASS}>
{snapContext ? <SnappingChips context={snapContext} /> : null} {snapContext ? <SnappingChips context={snapContext} /> : null}
{showWallChainMode ? <WallChainModeChip /> : null} {continuationContext ? <ContinuationChip context={continuationContext} /> : null}
{showFenceChainMode ? <FenceChainModeChip /> : null}
{showPaintScope ? <PaintScopeChip /> : null} {showPaintScope ? <PaintScopeChip /> : null}
{hints.map((hint) => ( {hints.map((hint) => (
<div <div
className={cn( className={cn(ROW_CLASS, 'items-start', hint.active && 'rounded-md bg-primary/10')}
PILL_CLASS,
'w-full justify-between',
hint.active && 'border-primary/40 bg-primary/10 text-foreground',
)}
key={`${hint.keys.join('+')}:${hint.label}`} key={`${hint.keys.join('+')}:${hint.label}`}
> >
<span className="min-w-0 flex-1 truncate font-medium leading-snug">{hint.label}</span>
<ShortcutSequence keys={hint.keys} /> <ShortcutSequence keys={hint.keys} />
<div className="min-w-0">
<div
className={cn(
'text-xs leading-5',
hint.active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{hint.label}
</div>
{hint.subtitle ? (
<div className="text-[10px] text-muted-foreground/70 leading-snug">
{hint.subtitle}
</div>
) : null}
</div>
</div> </div>
))} ))}
</div> </div>
@@ -16,10 +16,12 @@ import {
resolveRotateHandleHelpHints, resolveRotateHandleHelpHints,
resolveSelectModeHelpHints, resolveSelectModeHelpHints,
} from '../../../lib/contextual-help' } from '../../../lib/contextual-help'
import { continuationContextOf } from '../../../lib/continuation'
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation' import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
import type { ReshapeKind } from '../../../lib/interaction/scope' import type { ReshapeKind } from '../../../lib/interaction/scope'
import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
import { snapContextOf } from '../../../lib/snapping-mode' import { snapContextOf } from '../../../lib/snapping-mode'
import useEditor from '../../../store/use-editor' import useEditor, { getActiveContinuationContext } from '../../../store/use-editor'
import useInteractionScope, { import useInteractionScope, {
useActiveHandleDrag, useActiveHandleDrag,
useMovingNode, useMovingNode,
@@ -113,6 +115,10 @@ export function HelperManager() {
}), }),
[scope, mode, tool], [scope, mode, tool],
) )
const continuationContext = useMemo(
() => getActiveContinuationContext(),
[scope, mode, tool],
)
const selectModeHints = useMemo( const selectModeHints = useMemo(
() => () =>
resolveSelectModeHelpHints({ resolveSelectModeHelpHints({
@@ -144,10 +150,17 @@ export function HelperManager() {
if (movingNode) { if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate /> if (movingNode.type === 'building') return <BuildingHelper showRotate />
// A fresh placement (e.g. a positioned preset like a shelf) advertises its
// once/repeat continuation, exactly like the GLB item tool — but an existing
// node being *moved* is not a placement, so it gets no continuation chip.
const movingContinuationContext = isFreshPlacementMetadata(movingNode.metadata)
? continuationContextOf(movingNode.type)
: null
// Force-place only makes sense for kinds that collision-validate their drop; // Force-place only makes sense for kinds that collision-validate their drop;
// structural kinds (wall/slab/…) never reject, so don't advertise Alt. // structural kinds (wall/slab/…) never reject, so don't advertise Alt.
return ( return (
<ItemHelper <ItemHelper
continuationContext={movingContinuationContext}
showEsc showEsc
showForce={nodeRegistry.get(movingNode.type)?.snapProfile !== 'structural'} showForce={nodeRegistry.get(movingNode.type)?.snapProfile !== 'structural'}
snapContext={snapContext} snapContext={snapContext}
@@ -176,9 +189,8 @@ export function HelperManager() {
if (def?.toolHints && def.toolHints.length > 0) { if (def?.toolHints && def.toolHints.length > 0) {
return ( return (
<RegisteredToolHelper <RegisteredToolHelper
continuationContext={continuationContext}
hints={def.toolHints} hints={def.toolHints}
showFenceChainMode={mode === 'build' && tool === 'fence'}
showWallChainMode={mode === 'build' && tool === 'wall'}
shiftPressed={modifiers.shift} shiftPressed={modifiers.shift}
snapContext={snapContext} snapContext={snapContext}
/> />
@@ -1,3 +1,4 @@
import type { ContinuationContext } from '../../../lib/continuation'
import type { SnapContext } from '../../../lib/snapping-mode' import type { SnapContext } from '../../../lib/snapping-mode'
import { ContextualHelperPanel } from './contextual-helper-panel' import { ContextualHelperPanel } from './contextual-helper-panel'
@@ -7,13 +8,22 @@ interface ItemHelperProps {
// Whether to advertise Alt = force-place. Only meaningful for kinds that // Whether to advertise Alt = force-place. Only meaningful for kinds that
// collision-validate their drop (structural kinds never reject, so it's hidden). // collision-validate their drop (structural kinds never reject, so it's hidden).
showForce?: boolean showForce?: boolean
// Set for a fresh point-kind placement (e.g. a positioned preset) so the
// once/repeat continuation chip shows; null for an existing-node move.
continuationContext?: ContinuationContext | null
} }
// Snapping mode is the chip on the right (Shift cycles it), so it's not repeated // Snapping mode is the chip on the right (Shift cycles it), so it's not repeated
// as a key hint. Rotate is the two keys; Alt forces an invalid (red) drop. // as a key hint. Rotate is the two keys; Alt forces an invalid (red) drop.
export function ItemHelper({ showEsc, snapContext, showForce }: ItemHelperProps) { export function ItemHelper({
showEsc,
snapContext,
showForce,
continuationContext = null,
}: ItemHelperProps) {
return ( return (
<ContextualHelperPanel <ContextualHelperPanel
continuationContext={continuationContext}
hints={[ hints={[
{ keys: ['Left click'], label: 'Place' }, { keys: ['Left click'], label: 'Place' },
{ keys: ['R', 'T'], label: 'Rotate' }, { keys: ['R', 'T'], label: 'Rotate' },
@@ -1,4 +1,5 @@
import type { ToolHint } from '@pascal-app/core' import type { ToolHint } from '@pascal-app/core'
import type { ContinuationContext } from '../../../lib/continuation'
import type { SnapContext } from '../../../lib/snapping-mode' import type { SnapContext } from '../../../lib/snapping-mode'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ContextualHelperPanel } from './contextual-helper-panel' import { ContextualHelperPanel } from './contextual-helper-panel'
@@ -16,14 +17,12 @@ export function RegisteredToolHelper({
hints, hints,
shiftPressed = false, shiftPressed = false,
snapContext = null, snapContext = null,
showWallChainMode = false, continuationContext = null,
showFenceChainMode = false,
}: { }: {
hints: ToolHint[] hints: ToolHint[]
shiftPressed?: boolean shiftPressed?: boolean
snapContext?: SnapContext | null snapContext?: SnapContext | null
showWallChainMode?: boolean continuationContext?: ContinuationContext | null
showFenceChainMode?: boolean
}) { }) {
// Live vertex count of an in-progress polygon draft, so hints gated on a // Live vertex count of an in-progress polygon draft, so hints gated on a
// minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
@@ -36,8 +35,7 @@ export function RegisteredToolHelper({
!(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
) )
if (visible.length === 0 && !snapContext && !showWallChainMode && !showFenceChainMode) if (visible.length === 0 && !snapContext && !continuationContext) return null
return null
return ( return (
<ContextualHelperPanel <ContextualHelperPanel
hints={visible.map((hint) => { hints={visible.map((hint) => {
@@ -50,9 +48,8 @@ export function RegisteredToolHelper({
active: shiftPressed && isBypassHint, active: shiftPressed && isBypassHint,
} }
})} })}
continuationContext={continuationContext}
snapContext={snapContext} snapContext={snapContext}
showWallChainMode={showWallChainMode}
showFenceChainMode={showFenceChainMode}
/> />
) )
} }
@@ -53,16 +53,6 @@ export function ItemCatalog({
}) })
})() })()
const categoryItems = filteredItems
// Auto-select first item if current selection is not in the filtered list
useEffect(() => {
const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
if (!isCurrentItemInCategory && categoryItems.length > 0) {
setSelectedItem(categoryItems[0] as AssetInput)
}
}, [categoryItems, selectedItem?.src, setSelectedItem])
if (filteredItems.length === 0 && emptyState) { if (filteredItems.length === 0 && emptyState) {
return <>{emptyState}</> return <>{emptyState}</>
} }
@@ -22,6 +22,17 @@ const MOUSE_SHORTCUTS = {
}, },
} as const } as const
// The platform-agnostic command modifier. Both Cmd and Ctrl bind the action; we
// render the symbol for the *current* device so the hint reads native (⌘ on Mac,
// Ctrl elsewhere) without implying only one of them works.
const COMMAND_VALUES = new Set(['Cmd/Ctrl', 'Cmd', 'Command', 'Meta'])
// Resolved once on the client at module load — the editor HUD is client-only, so
// there's no server render to mismatch against. `navigator.platform` is enough
// here and matches the detection used elsewhere (floorplan rotate hint).
const IS_MAC =
typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC')
type ShortcutTokenProps = React.ComponentProps<'kbd'> & { type ShortcutTokenProps = React.ComponentProps<'kbd'> & {
value: string value: string
displayValue?: string displayValue?: string
@@ -30,16 +41,19 @@ type ShortcutTokenProps = React.ComponentProps<'kbd'> & {
function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) { function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) {
const mouseShortcut = const mouseShortcut =
value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null
const isCommand = COMMAND_VALUES.has(value)
const commandDisplay = IS_MAC ? '⌘' : 'Ctrl'
const commandLabel = IS_MAC ? 'Command' : 'Control'
return ( return (
<kbd <kbd
aria-label={mouseShortcut?.label ?? displayValue ?? value} aria-label={mouseShortcut?.label ?? (isCommand ? commandLabel : (displayValue ?? value))}
className={cn( className={cn(
'inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground', 'inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground',
mouseShortcut && 'justify-center px-1.5', mouseShortcut && 'justify-center px-1.5',
className, className,
)} )}
title={mouseShortcut?.label ?? value} title={mouseShortcut?.label ?? (isCommand ? commandLabel : value)}
{...props} {...props}
> >
{mouseShortcut ? ( {mouseShortcut ? (
@@ -54,6 +68,10 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok
/> />
<span className="sr-only">{mouseShortcut.label}</span> <span className="sr-only">{mouseShortcut.label}</span>
</> </>
) : isCommand ? (
// The ⌘ glyph reads small next to letters at the same font size, so bump
// it up a touch on Mac. "Ctrl" stays at the token's normal size.
<span className={IS_MAC ? 'text-[13px] leading-none' : undefined}>{commandDisplay}</span>
) : ( ) : (
(displayValue ?? value) (displayValue ?? value)
)} )}
+21 -38
View File
@@ -10,7 +10,7 @@ import {
} from '../lib/scene-clipboard' } from '../lib/scene-clipboard'
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus' import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
import { toggleWindowOpenState } from '../lib/window-interaction' import { toggleWindowOpenState } from '../lib/window-interaction'
import useEditor, { getActiveSnapContext } from '../store/use-editor' import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope' import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
// Tools call this in their onCancel handler when they have an active mid-action to cancel, // Tools call this in their onCancel handler when they have an active mid-action to cancel,
@@ -48,42 +48,24 @@ export const useKeyboard = ({
// shows a snapping chip. That single source covers wall/fence/item drafting, // shows a snapping chip. That single source covers wall/fence/item drafting,
// every node move (including wall-hosted items + door/window openings, which // every node move (including wall-hosted items + door/window openings, which
// now declare `snapProfile`), and endpoint/polygon reshaping, so the keys // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
// never silently stop working. (Force-place lives on Alt outside wall drafting.) // never silently stop working. Force-place lives on Alt where a tool supports it.
const isSnappingCycleContext = () => getActiveSnapContext() != null const isSnappingCycleContext = () => getActiveSnapContext() != null
const isWallDraftingActive = () => {
const ed = useEditor.getState()
return ed.mode === 'build' && ed.tool === 'wall'
}
const isFenceDraftingActive = () => {
const ed = useEditor.getState()
return ed.mode === 'build' && ed.tool === 'fence'
}
// Alt-tap cycles the active drafting tool's chain mode (wall room/single,
// fence continuous/single). Only one of these is ever active at a time.
const isChainModeContext = () => isWallDraftingActive() || isFenceDraftingActive()
// A "clean tap" of Ctrl/Meta (pressed and released with NO other key in // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
// between) cycles the grid step — same context as the Shift snapping-mode // between) cycles the grid step — same context as the Shift snapping-mode
// cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone // cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
// and is cleared the instant any other key fires, so chords like Ctrl+Z / // and is cleared the instant any other key fires, so chords like Ctrl+Z /
// Ctrl+C never cycle. // Ctrl+C never cycle.
let ctrlTapClean = false let ctrlTapClean = false
let altTapClean = false
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') { if (e.key === 'Control' || e.key === 'Meta') {
// Only a fresh, modifier-free press starts a clean-tap candidate; // Only a fresh, modifier-free press starts a clean-tap candidate;
// ignore key-repeat and presses already part of a combo. // ignore key-repeat and presses already part of a combo.
ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey
altTapClean = false
} else if (e.key === 'Alt') {
altTapClean = !e.repeat && !e.shiftKey && !e.ctrlKey && !e.metaKey && isChainModeContext()
ctrlTapClean = false
} else { } else {
// Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks // Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
// the clean tap. // the clean tap.
ctrlTapClean = false ctrlTapClean = false
altTapClean = false
} }
// Don't handle shortcuts if user is typing in an input // Don't handle shortcuts if user is typing in an input
@@ -110,6 +92,23 @@ export const useKeyboard = ({
return return
} }
if (
(e.key === 'c' || e.key === 'C') &&
!e.repeat &&
!e.metaKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.altKey
) {
const context = getActiveContinuationContext()
if (context) {
e.preventDefault()
useEditor.getState().cycleContinuation(context)
sfxEmitter.emit('sfx:grid-snap')
return
}
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
_toolCancelConsumed = false _toolCancelConsumed = false
@@ -434,8 +433,8 @@ export const useKeyboard = ({
const wasClean = ctrlTapClean const wasClean = ctrlTapClean
ctrlTapClean = false ctrlTapClean = false
if (!wasClean) return if (!wasClean) return
// Same scope as the Shift snapping-mode cycle: wall / fence build only, // Same scope as the Shift snapping-mode cycle, and never while typing
// and never while typing in an input. // in an input.
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return return
} }
@@ -445,22 +444,6 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
return return
} }
if (e.key !== 'Alt') return
const wasClean = altTapClean
altTapClean = false
if (!wasClean) return
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
if (isWallDraftingActive()) {
useEditor.getState().cycleWallChainMode()
} else if (isFenceDraftingActive()) {
useEditor.getState().cycleFenceChainMode()
} else {
return
}
sfxEmitter.emit('sfx:grid-snap')
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
+11
View File
@@ -88,6 +88,7 @@ export {
} from './components/tools/item/use-placement-coordinator' } from './components/tools/item/use-placement-coordinator'
export { CursorSphere } from './components/tools/shared/cursor-sphere' export { CursorSphere } from './components/tools/shared/cursor-sphere'
export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' 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 { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview'
export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility' export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility'
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
@@ -219,6 +220,13 @@ export {
} from './lib/ceiling-plan-snap' } from './lib/ceiling-plan-snap'
export { EDITOR_LAYER } from './lib/constants' export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels. // Helper libs used by the kind-owned roof / stair / elevator panels.
export {
CONTINUATION_PROFILES,
type ContinuationContext,
type ContinuationMode,
continuationContextOf,
nextContinuation,
} from './lib/continuation'
export { export {
resolveCurrentBuildingId, resolveCurrentBuildingId,
resolveElevatorNodeSupportY, resolveElevatorNodeSupportY,
@@ -335,10 +343,13 @@ export type {
} from './store/use-editor' } from './store/use-editor'
export { export {
default as useEditor, default as useEditor,
getActiveContinuationContext,
getContinuation,
isAngleSnapActive, isAngleSnapActive,
isGridSnapActive, isGridSnapActive,
isMagneticSnapActive, isMagneticSnapActive,
} from './store/use-editor' } from './store/use-editor'
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
export { export {
default as useInteractionScope, default as useInteractionScope,
getEditingHole, getEditingHole,
@@ -0,0 +1,35 @@
import { Vector3 } from 'three'
// The surface the active placement/move ghost is currently snapped to: a contact
// point (world space) and the surface's outward unit normal. Published each frame
// by the placement tools (the item coordinator + the drawn-kind tools) and read
// by the grid so its snap patch sits at the ghost's height AND orients to the
// surface — horizontal on a floor / shelf top, vertical in a wall plane.
//
// A plain module singleton (not a store): both writer and reader run inside
// `useFrame`, so reactivity would only add overhead. The vectors are reused, so
// readers must consume them within the same frame.
export type PlacementSurface = {
point: Vector3
normal: Vector3
}
const surface: PlacementSurface = {
point: new Vector3(),
normal: new Vector3(0, 1, 0),
}
let active = false
export function publishPlacementSurface(point: Vector3, normal: Vector3): void {
surface.point.copy(point)
surface.normal.copy(normal)
active = true
}
export function clearPlacementSurface(): void {
active = false
}
export function getPlacementSurface(): PlacementSurface | null {
return active ? surface : null
}
@@ -1,6 +1,9 @@
export type ContextualShortcutHint = { export type ContextualShortcutHint = {
keys: string[] keys: string[]
label: string label: string
// Optional secondary line under the label for a terser qualifier
// (e.g. "disable 15° snap"). The HUD wraps both lines rather than truncating.
subtitle?: string
active?: boolean active?: boolean
} }
+49
View File
@@ -0,0 +1,49 @@
export type ContinuationContext = 'wall' | 'fence' | 'point'
export type ContinuationMode = string
export const CONTINUATION_PROFILES: Record<
ContinuationContext,
{
options: ContinuationMode[]
default: ContinuationMode
labels: Record<string, string>
icons: Record<string, string>
}
> = {
wall: {
options: ['room', 'single'],
default: 'room',
labels: { room: 'Room (auto-close)', single: 'Single wall' },
icons: { room: 'lucide:square', single: 'lucide:minus' },
},
fence: {
options: ['continuous', 'single'],
default: 'continuous',
labels: { continuous: 'Continuous', single: 'Single fence' },
icons: { continuous: 'lucide:waypoints', single: 'lucide:minus' },
},
point: {
options: ['once', 'repeat'],
default: 'once',
labels: { once: 'Place once', repeat: 'Place multiple' },
icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' },
},
}
const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column'])
export function nextContinuation(
context: ContinuationContext,
current: ContinuationMode,
): ContinuationMode {
const profile = CONTINUATION_PROFILES[context]
const index = profile.options.indexOf(current)
if (index === -1) return profile.default
return profile.options[(index + 1) % profile.options.length] ?? profile.default
}
export function continuationContextOf(kind: string): ContinuationContext | null {
if (kind === 'wall') return 'wall'
if (kind === 'fence') return 'fence'
return POINT_KINDS.has(kind) ? 'point' : null
}
+87 -32
View File
@@ -34,6 +34,13 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import {
CONTINUATION_PROFILES,
type ContinuationContext,
type ContinuationMode,
continuationContextOf,
nextContinuation,
} from '../lib/continuation'
import { import {
type ActivePaintMaterial, type ActivePaintMaterial,
type PaintableMaterialTarget, type PaintableMaterialTarget,
@@ -152,8 +159,6 @@ export type StructureLayer = 'zones' | 'elements'
export type FloorplanSelectionTool = 'click' | 'marquee' export type FloorplanSelectionTool = 'click' | 'marquee'
export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05 export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
export type WallChainMode = 'room' | 'single'
export type FenceChainMode = 'continuous' | 'single'
export type NavigationSyncSource = '2d' | '3d' export type NavigationSyncSource = '2d' | '3d'
@@ -312,6 +317,12 @@ type EditorState = {
// surface" hint). Set by the selection-manager paint hover; not persisted. // surface" hint). Set by the selection-manager paint hover; not persisted.
paintHover: PaintHoverInfo | null paintHover: PaintHoverInfo | null
setPaintHover: (info: PaintHoverInfo | null) => void setPaintHover: (info: PaintHoverInfo | null) => void
// Embedder capability: true when a host (e.g. community) can locate a selected
// node in its catalog browser. Gates the node action menu's "Find" button; the
// editor itself emits `selection:find-node` and lets the host fulfil it. Not
// persisted — it's a per-mount capability the host registers.
canFindNode: boolean
setCanFindNode: (canFind: boolean) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
guideUi: Record<string, GuideUiState> guideUi: Record<string, GuideUiState>
@@ -375,12 +386,10 @@ type EditorState = {
setSnappingMode: (context: SnapContext, mode: SnappingMode) => void setSnappingMode: (context: SnapContext, mode: SnappingMode) => void
// Cycle the *active* context's mode within its own set; returns the new value. // Cycle the *active* context's mode within its own set; returns the new value.
cycleSnappingMode: () => SnappingMode cycleSnappingMode: () => SnappingMode
wallChainMode: WallChainMode continuationByContext: Record<ContinuationContext, ContinuationMode>
setWallChainMode: (mode: WallChainMode) => void setContinuation: (context: ContinuationContext, mode: ContinuationMode) => void
cycleWallChainMode: () => WallChainMode cycleContinuation: (context: ContinuationContext) => ContinuationMode
fenceChainMode: FenceChainMode getContinuation: (context: ContinuationContext) => ContinuationMode
setFenceChainMode: (mode: FenceChainMode) => void
cycleFenceChainMode: () => FenceChainMode
showReferenceFloor: boolean showReferenceFloor: boolean
toggleReferenceFloor: () => void toggleReferenceFloor: () => void
setShowReferenceFloor: (show: boolean) => void setShowReferenceFloor: (show: boolean) => void
@@ -430,8 +439,7 @@ type PersistedEditorLayoutState = Pick<
| 'gridSnapStep' | 'gridSnapStep'
| 'magneticSnap' | 'magneticSnap'
| 'snappingModeByContext' | 'snappingModeByContext'
| 'wallChainMode' | 'continuationByContext'
| 'fenceChainMode'
| 'showReferenceFloor' | 'showReferenceFloor'
| 'referenceFloorOffset' | 'referenceFloorOffset'
| 'referenceFloorOpacity' | 'referenceFloorOpacity'
@@ -460,8 +468,11 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
item: defaultSnappingModeFor('item'), item: defaultSnappingModeFor('item'),
polygon: defaultSnappingModeFor('polygon'), polygon: defaultSnappingModeFor('polygon'),
}, },
wallChainMode: 'room', continuationByContext: {
fenceChainMode: 'continuous', wall: CONTINUATION_PROFILES.wall.default,
fence: CONTINUATION_PROFILES.fence.default,
point: CONTINUATION_PROFILES.point.default,
},
showReferenceFloor: false, showReferenceFloor: false,
referenceFloorOffset: 1, referenceFloorOffset: 1,
referenceFloorOpacity: 0.35, referenceFloorOpacity: 0.35,
@@ -572,16 +583,40 @@ function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode
: defaultSnappingModeFor(context) : defaultSnappingModeFor(context)
} }
function migrateWallChainMode(value: unknown): WallChainMode { type LegacyContinuationState = {
return value === 'single' || value === 'room' ? value : 'room' continuationByContext?: Partial<Record<ContinuationContext, unknown>>
wallChainMode?: unknown
fenceChainMode?: unknown
} }
function migrateFenceChainMode(value: unknown): FenceChainMode { function migrateContinuationMode(
return value === 'single' || value === 'continuous' ? value : 'continuous' value: unknown,
context: ContinuationContext,
): ContinuationMode | null {
const profile = CONTINUATION_PROFILES[context]
return profile.options.includes(value as ContinuationMode) ? (value as ContinuationMode) : null
}
function normalizeContinuationByContext(
state: LegacyContinuationState | null | undefined,
): Record<ContinuationContext, ContinuationMode> {
return {
wall:
migrateContinuationMode(state?.continuationByContext?.wall, 'wall') ??
migrateContinuationMode(state?.wallChainMode, 'wall') ??
CONTINUATION_PROFILES.wall.default,
fence:
migrateContinuationMode(state?.continuationByContext?.fence, 'fence') ??
migrateContinuationMode(state?.fenceChainMode, 'fence') ??
CONTINUATION_PROFILES.fence.default,
point:
migrateContinuationMode(state?.continuationByContext?.point, 'point') ??
CONTINUATION_PROFILES.point.default,
}
} }
function normalizePersistedEditorLayoutState( function normalizePersistedEditorLayoutState(
state: Partial<PersistedEditorLayoutState> | null | undefined, state: (Partial<PersistedEditorLayoutState> & LegacyContinuationState) | null | undefined,
): PersistedEditorLayoutState { ): PersistedEditorLayoutState {
return { return {
activeSidebarPanel: activeSidebarPanel:
@@ -601,8 +636,7 @@ function normalizePersistedEditorLayoutState(
item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'), item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'), polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
}, },
wallChainMode: migrateWallChainMode(state?.wallChainMode), continuationByContext: normalizeContinuationByContext(state),
fenceChainMode: migrateFenceChainMode(state?.fenceChainMode),
showReferenceFloor: state?.showReferenceFloor === true, showReferenceFloor: state?.showReferenceFloor === true,
referenceFloorOffset: referenceFloorOffset:
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -932,6 +966,8 @@ const useEditor = create<EditorState>()(
}, },
paintHover: null, paintHover: null,
setPaintHover: (info) => set({ paintHover: info }), setPaintHover: (info) => set({ paintHover: info }),
canFindNode: false,
setCanFindNode: (canFind) => set({ canFindNode: canFind }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
guideUi: {}, guideUi: {},
@@ -1070,19 +1106,24 @@ const useEditor = create<EditorState>()(
})) }))
return next return next
}, },
wallChainMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.wallChainMode, continuationByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.continuationByContext,
setWallChainMode: (mode) => set({ wallChainMode: mode }), setContinuation: (context, mode) => {
cycleWallChainMode: () => { const next =
const next = get().wallChainMode === 'room' ? 'single' : 'room' migrateContinuationMode(mode, context) ?? CONTINUATION_PROFILES[context].default
set({ wallChainMode: next }) set((state) => ({
continuationByContext: { ...state.continuationByContext, [context]: next },
}))
},
cycleContinuation: (context) => {
const next = nextContinuation(context, get().getContinuation(context))
set((state) => ({
continuationByContext: { ...state.continuationByContext, [context]: next },
}))
return next return next
}, },
fenceChainMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.fenceChainMode, getContinuation: (context) => {
setFenceChainMode: (mode) => set({ fenceChainMode: mode }), const current = get().continuationByContext[context]
cycleFenceChainMode: () => { return migrateContinuationMode(current, context) ?? CONTINUATION_PROFILES[context].default
const next = get().fenceChainMode === 'continuous' ? 'single' : 'continuous'
set({ fenceChainMode: next })
return next
}, },
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
toggleReferenceFloor: () => toggleReferenceFloor: () =>
@@ -1177,8 +1218,7 @@ const useEditor = create<EditorState>()(
gridSnapStep: state.gridSnapStep, gridSnapStep: state.gridSnapStep,
magneticSnap: state.magneticSnap, magneticSnap: state.magneticSnap,
snappingModeByContext: state.snappingModeByContext, snappingModeByContext: state.snappingModeByContext,
wallChainMode: state.wallChainMode, continuationByContext: state.continuationByContext,
fenceChainMode: state.fenceChainMode,
showReferenceFloor: state.showReferenceFloor, showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset, referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity, referenceFloorOpacity: state.referenceFloorOpacity,
@@ -1233,6 +1273,21 @@ export function getActiveSnapContext(): SnapContext | null {
}) })
} }
export function getActiveContinuationContext(): ContinuationContext | null {
const scope = useInteractionScope.getState().scope
if (scope.kind === 'drafting') return continuationContextOf(scope.tool)
if (scope.kind === 'placing') return continuationContextOf(scope.nodeType)
if (scope.kind !== 'idle') return null
const editor = useEditor.getState()
if (editor.mode !== 'build' || !editor.tool) return null
return continuationContextOf(editor.tool)
}
export function getContinuation(context: ContinuationContext): ContinuationMode {
return useEditor.getState().getContinuation(context)
}
/** /**
* The effective snapping mode for the active context. Falls back to `item`'s * The effective snapping mode for the active context. Falls back to `item`'s
* default (free) when no snappable context is active, so a stray reader never * default (free) when no snappable context is active, so a stray reader never
@@ -0,0 +1,44 @@
// Ephemeral store for the forward-facing floor triangle shown while placing or
// moving a node. A single editor-side overlay (`<FacingPoseIndicator>`)
// subscribes and renders the triangle; every placement/move path publishes its
// ghost pose here instead of drawing its own triangle. This is deliberately the
// one renderer for the facing indicator: rendering it from inside a tool's own
// cursor ghost (especially tools living in `@pascal-app/nodes`) left it
// invisible, while the editor-side overlay renders reliably. Producers clear on
// commit, cancel, and unmount.
//
// Poses are in the same building-local frame the tools already work in (the
// overlay is mounted inside ToolManager's building-local group).
import { create } from 'zustand'
export type FacingPose = {
/** Ghost origin in building-local space. */
position: [number, number, number]
/** Ghost yaw (radians). The triangle inherits this so it points where the
* node faces. */
rotationY: number
/** Footprint depth along the ghost's local +Z; the triangle sits just past
* `center[1] + depth / 2`. */
depth: number
/** Footprint centre offset `[x, z]` in the ghost's local frame. Defaults to
* the origin. Kinds whose forward edge isn't centred on the origin (e.g. a
* stair, whose run starts at the entry) shift the triangle via this. */
center?: [number, number]
/** Point along local -Z (the front is the -Z side) instead of +Z. */
reversed?: boolean
}
type FacingPoseState = {
pose: FacingPose | null
set(pose: FacingPose): void
clear(): void
}
const useFacingPose = create<FacingPoseState>((set) => ({
pose: null,
set: (pose) => set({ pose }),
clear: () => set({ pose: null }),
}))
export default useFacingPose
+1
View File
@@ -315,6 +315,7 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
export const columnDefinition: NodeDefinition<typeof ColumnNode> = { export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'column', kind: 'column',
snapProfile: 'item', snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1, schemaVersion: 1,
schema: ColumnNode, schema: ColumnNode,
category: 'structure', category: 'structure',
+18 -4
View File
@@ -16,6 +16,7 @@ import {
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor, useEditor,
useFacingPose,
usePlacementPreview, usePlacementPreview,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -101,6 +102,13 @@ const ColumnTool = () => {
levelId: activeLevelId, levelId: activeLevelId,
}) })
cursorRef.current?.position.set(...visualPosition) 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 lastCursorRef.current = position
// Publish a transient, positioned preview node for the 2D floor-plan // Publish a transient, positioned preview node for the 2D floor-plan
@@ -130,12 +138,17 @@ const ColumnTool = () => {
useScene.getState().createNode(column, activeLevelId) useScene.getState().createNode(column, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [column.id] }) useViewer.getState().setSelection({ selectedIds: [column.id] })
triggerSFX('sfx:structure-build') 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() useAlignmentGuides.getState().clear()
usePlacementPreview.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) stopPlacementCommitPropagation(event)
} }
@@ -147,6 +160,7 @@ const ColumnTool = () => {
unsubscribePlacementClicks() unsubscribePlacementClicks()
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear() usePlacementPreview.getState().clear()
useFacingPose.getState().clear()
} }
}, [activeLevelId, previewNode]) }, [activeLevelId, previewNode])
+1
View File
@@ -167,6 +167,7 @@ const doorHandles: HandleDescriptor<DoorNodeType>[] = [
export const doorDefinition: NodeDefinition<typeof DoorNode> = { export const doorDefinition: NodeDefinition<typeof DoorNode> = {
kind: 'door', kind: 'door',
snapProfile: 'item', snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1, schemaVersion: 1,
schema: DoorNode, schema: DoorNode,
category: 'structure', category: 'structure',
+23 -4
View File
@@ -16,11 +16,13 @@ import {
calculateCursorRotation, calculateCursorRotation,
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
FacingIndicator,
getSideFromNormal, getSideFromNormal,
isMagneticSnapActive, isMagneticSnapActive,
isValidWallSideFace, isValidWallSideFace,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -73,6 +75,7 @@ type HostKind = 'wall' | 'roof' | null
const DoorTool: React.FC = () => { const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | null>(null) const draftRef = useRef<DoorNode | null>(null)
const cursorGroupRef = useRef<Group>(null!) const cursorGroupRef = useRef<Group>(null!)
const indicatorYOffsetRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!) const edgesRef = useRef<LineSegments>(null!)
// Off-host floating ghost: the real door geometry follows the cursor over // Off-host floating ghost: the real door geometry follows the cursor over
@@ -155,6 +158,7 @@ const DoorTool: React.FC = () => {
worldPosition: [number, number, number], worldPosition: [number, number, number],
cursorRotationY: number, cursorRotationY: number,
valid: boolean, valid: boolean,
indicatorYOffset: number,
) => { ) => {
setFallbackPose(null) setFallbackPose(null)
const group = cursorGroupRef.current const group = cursorGroupRef.current
@@ -162,6 +166,7 @@ const DoorTool: React.FC = () => {
group.visible = true group.visible = true
group.position.set(...worldPosition) group.position.set(...worldPosition)
group.rotation.y = cursorRotationY group.rotation.y = cursorRotationY
indicatorYOffsetRef.current?.position.set(0, indicatorYOffset, 0)
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
} }
@@ -282,6 +287,7 @@ const DoorTool: React.FC = () => {
), ),
cursorRotationY, cursorRotationY,
valid, valid,
-clampedY,
) )
if (draftRef.current) { if (draftRef.current) {
@@ -358,11 +364,16 @@ const DoorTool: React.FC = () => {
useScene.getState().createNode(node, wall.id as AnyNodeId) useScene.getState().createNode(node, wall.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build') triggerSFX('sfx:structure-build')
alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
clearOpeningGuides3D() 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 ────────────────────────────────────── // ── Direct wall-mesh hover ──────────────────────────────────────
@@ -469,7 +480,7 @@ const DoorTool: React.FC = () => {
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const pose = getRoofWallOpeningCursorPose(target, roof) 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) => { const onRoofHover = (event: RoofEvent) => {
@@ -568,8 +579,13 @@ const DoorTool: React.FC = () => {
// picks up the new opening cut. // picks up the new opening cut.
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId) useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build') triggerSFX('sfx:structure-build')
if (useEditor.getState().getContinuation('point') === 'repeat') {
useScene.temporal.getState().pause()
} else {
hideCursor()
useEditor.getState().setTool(null)
}
event.stopPropagation() event.stopPropagation()
} }
@@ -661,6 +677,9 @@ const DoorTool: React.FC = () => {
material={edgeMaterial} material={edgeMaterial}
ref={edgesRef} ref={edgesRef}
/> />
<group ref={indicatorYOffsetRef}>
<FacingIndicator depth={ghostStub.frameDepth} />
</group>
</group> </group>
{fallbackPose && ( {fallbackPose && (
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}> <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. // While drafting, the segment locks to 15° rays from its start.
// Snapping is governed by the snapping mode (`'off'` is the bypass); // Snapping is governed by the snapping mode (`'off'` is the bypass);
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap // 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() const bypassAlign = !isMagneticSnapActive()
if (buildingState.current === 1) { 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 // Single mode commits one segment per click: stop drafting so the next
// click starts a fresh segment instead of chaining off this endpoint. // click starts a fresh segment instead of chaining off this endpoint.
if (useEditor.getState().fenceChainMode === 'single') { if (useEditor.getState().getContinuation('fence') === 'single') {
stopDrafting() stopDrafting()
return return
} }
+1
View File
@@ -167,6 +167,7 @@ function itemWallMoveHandle(): HandleDescriptor<ItemNodeType> {
export const itemDefinition: NodeDefinition<typeof ItemNode> = { export const itemDefinition: NodeDefinition<typeof ItemNode> = {
kind: 'item', kind: 'item',
snapProfile: 'item', snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1, schemaVersion: 1,
schema: ItemNode, schema: ItemNode,
category: 'furnish', category: 'furnish',
+1 -4
View File
@@ -35,10 +35,7 @@ function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) {
}, },
onCommitted: () => { onCommitted: () => {
triggerSFX('sfx:item-place') triggerSFX('sfx:item-place')
// Returning `true` tells the coordinator to immediately spawn the return useEditor.getState().getContinuation('point') === 'repeat'
// next draft so the user can keep placing copies — matches the
// "repeat-on-click" UX of the legacy tool.
return true
}, },
}) })
+1
View File
@@ -133,6 +133,7 @@ function shelfHandles(_node: ShelfNodeType): HandleDescriptor<ShelfNodeType>[] {
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = { export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
kind: 'shelf', kind: 'shelf',
snapProfile: 'item', snapProfile: 'item',
facingIndicator: true,
schemaVersion: 2, schemaVersion: 2,
schema: ShelfNode, schema: ShelfNode,
category: 'furnish', category: 'furnish',
+8 -3
View File
@@ -128,10 +128,15 @@ const ShelfTool = () => {
useScene.getState().createNode(shelf, activeLevelId) useScene.getState().createNode(shelf, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [shelf.id] }) useViewer.getState().setSelection({ selectedIds: [shelf.id] })
triggerSFX('sfx:item-place') 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() 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) stopPlacementCommitPropagation(event)
} }
+4
View File
@@ -422,6 +422,10 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
schema: StairNode, schema: StairNode,
category: 'structure', category: 'structure',
snapProfile: 'structural', 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 // 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 // mode. The toolHints presence routes it through the contextual HUD so the
// snapping chip shows during placement. // snapping chip shows during placement.
+1 -2
View File
@@ -705,8 +705,7 @@ export const WallTool: React.FC = () => {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear() useWallSnapIndicator.getState().clear()
const wallChainMode = useEditor.getState().wallChainMode if (useEditor.getState().getContinuation('wall') === 'single') {
if (wallChainMode === 'single') {
stopDrafting() stopDrafting()
return return
} }
+1
View File
@@ -161,6 +161,7 @@ const windowHandles: HandleDescriptor<WindowNodeType>[] = [
export const windowDefinition: NodeDefinition<typeof WindowNode> = { export const windowDefinition: NodeDefinition<typeof WindowNode> = {
kind: 'window', kind: 'window',
snapProfile: 'item', snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1, schemaVersion: 1,
schema: WindowNode, schema: WindowNode,
category: 'structure', category: 'structure',
+23 -4
View File
@@ -16,12 +16,14 @@ import {
calculateCursorRotation, calculateCursorRotation,
calculateItemRotation, calculateItemRotation,
EDITOR_LAYER, EDITOR_LAYER,
FacingIndicator,
getSideFromNormal, getSideFromNormal,
isMagneticSnapActive, isMagneticSnapActive,
isValidWallSideFace, isValidWallSideFace,
snapToHalf, snapToHalf,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -86,6 +88,7 @@ type HostKind = 'wall' | 'roof' | null
const WindowTool: React.FC = () => { const WindowTool: React.FC = () => {
const draftRef = useRef<WindowNode | null>(null) const draftRef = useRef<WindowNode | null>(null)
const cursorGroupRef = useRef<Group>(null!) const cursorGroupRef = useRef<Group>(null!)
const indicatorYOffsetRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!) const edgesRef = useRef<LineSegments>(null!)
// Off-host floating ghost: the real window geometry follows the cursor // Off-host floating ghost: the real window geometry follows the cursor
@@ -169,6 +172,7 @@ const WindowTool: React.FC = () => {
worldPosition: [number, number, number], worldPosition: [number, number, number],
cursorRotationY: number, cursorRotationY: number,
valid: boolean, valid: boolean,
indicatorYOffset: number,
) => { ) => {
setFallbackPose(null) setFallbackPose(null)
const group = cursorGroupRef.current const group = cursorGroupRef.current
@@ -176,6 +180,7 @@ const WindowTool: React.FC = () => {
group.visible = true group.visible = true
group.position.set(...worldPosition) group.position.set(...worldPosition)
group.rotation.y = cursorRotationY group.rotation.y = cursorRotationY
indicatorYOffsetRef.current?.position.set(0, indicatorYOffset, 0)
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
} }
@@ -341,6 +346,7 @@ const WindowTool: React.FC = () => {
), ),
cursorRotationY, cursorRotationY,
valid, valid,
-clampedY,
) )
if (draftRef.current) { if (draftRef.current) {
@@ -411,11 +417,16 @@ const WindowTool: React.FC = () => {
useScene.getState().createNode(node, wall.id as AnyNodeId) useScene.getState().createNode(node, wall.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build') triggerSFX('sfx:structure-build')
alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
clearOpeningGuides3D() 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 ────────────────────────────────────── // ── Direct wall-mesh hover ──────────────────────────────────────
@@ -531,7 +542,7 @@ const WindowTool: React.FC = () => {
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const pose = getRoofWallOpeningCursorPose(target, roof) 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) => { const onRoofHover = (event: RoofEvent) => {
@@ -624,8 +635,13 @@ const WindowTool: React.FC = () => {
// picks up the new opening cut. // picks up the new opening cut.
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId) useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build') triggerSFX('sfx:structure-build')
if (useEditor.getState().getContinuation('point') === 'repeat') {
useScene.temporal.getState().pause()
} else {
hideCursor()
useEditor.getState().setTool(null)
}
event.stopPropagation() event.stopPropagation()
} }
@@ -714,6 +730,9 @@ const WindowTool: React.FC = () => {
material={edgeMaterial} material={edgeMaterial}
ref={edgesRef} ref={edgesRef}
/> />
<group ref={indicatorYOffsetRef}>
<FacingIndicator depth={ghostStub.frameDepth} />
</group>
</group> </group>
{fallbackPose && ( {fallbackPose && (
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}> <group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
+3 -5
View File
@@ -131,11 +131,9 @@ There is no per-kind snapping switch.
These resolve the mode from the scope via `getActiveSnapContext()``snappingModeByContext[context]`. These resolve the mode from the scope via `getActiveSnapContext()``snappingModeByContext[context]`.
- **Modifiers.** Shift (tap) cycles the mode for the active context; Ctrl (tap) cycles the grid step; - **Modifiers.** Shift (tap) cycles the mode for the active context; Ctrl (tap) cycles the grid step;
Alt (hold) is force / free (raw cursor + commit past invalid; for MEP runs, the vertical-riser carve-out). Alt (hold) is force / free (raw cursor + commit past invalid; for MEP runs, the vertical-riser carve-out).
Shift is **not** a snap bypass. Alt is **not** a snap toggle. **Exception wall/fence drafting:** those Shift is **not** a snap bypass. Alt is **not** a snap toggle. Placement continuation (wall room/single,
tools have no force role (a wall/fence always places), so a clean **Alt-tap** cycles the chain mode fence continuous/single, point once/repeat) is a separate per-context mode, cycled by **C** and surfaced as
(`wallChainMode` room/single, `fenceChainMode` continuous/single) — wired in `hooks/use-keyboard.ts` via a clickable HUD chip.
`isChainModeContext()`, persisted in `useEditor`, surfaced as a clickable HUD chip. This is the one
sanctioned Alt-as-toggle, and only where Alt-as-force is meaningless.
- **The chip is the scope's.** The contextual HUD shows the active context's mode and is the only place the - **The chip is the scope's.** The contextual HUD shows the active context's mode and is the only place the
mode is cycled — so a tool that wants its chip must run inside a scope whose `snapContextOf` resolves mode is cycled — so a tool that wants its chip must run inside a scope whose `snapContextOf` resolves
(a build tool, `drafting`, `placing`/`moving`, or `reshaping`). (a build tool, `drafting`, `placing`/`moving`, or `reshaping`).