From 68e5c6ca67d9e2b77bd002f28cb9ff892cacee04 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 26 Jun 2026 21:48:58 -0400 Subject: [PATCH] feat(editor): unify placement/move facing triangle into one editor-side renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every placement and move path now publishes its ghost pose to a single `useFacingPose` store, drawn by one editor-side `` 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 --- packages/core/src/registry/index.ts | 1 + packages/core/src/registry/registry.ts | 12 + packages/core/src/registry/types.ts | 8 + .../editor/custom-camera-controls.tsx | 4 +- .../editor/floating-action-menu.tsx | 13 + .../src/components/editor/floorplan-panel.tsx | 4 +- .../editor/src/components/editor/grid.tsx | 143 +++++-- .../editor/src/components/editor/index.tsx | 132 ++++++- .../components/editor/node-action-menu.tsx | 15 +- .../components/editor/node-arrow-handles.tsx | 85 +---- .../components/editor/selection-manager.tsx | 68 ++++ .../use-floorplan-background-placement.ts | 2 +- .../tools/item/use-placement-coordinator.tsx | 181 +++++++-- .../registry/move-registry-node-tool.tsx | 19 + .../tools/shared/drag-bounding-box.tsx | 27 +- .../tools/shared/facing-indicator.tsx | 87 +++++ .../tools/shared/facing-pose-indicator.tsx | 54 +++ .../src/components/tools/stair/stair-tool.tsx | 30 +- .../src/components/tools/tool-manager.tsx | 5 + .../components/ui/helpers/building-helper.tsx | 33 +- .../ui/helpers/contextual-helper-panel.tsx | 356 ++++++++---------- .../components/ui/helpers/helper-manager.tsx | 18 +- .../src/components/ui/helpers/item-helper.tsx | 12 +- .../ui/helpers/registered-tool-helper.tsx | 13 +- .../ui/item-catalog/item-catalog.tsx | 10 - .../ui/primitives/shortcut-token.tsx | 22 +- packages/editor/src/hooks/use-keyboard.ts | 59 ++- packages/editor/src/index.tsx | 11 + .../src/lib/active-placement-surface.ts | 35 ++ packages/editor/src/lib/contextual-help.ts | 3 + packages/editor/src/lib/continuation.ts | 49 +++ packages/editor/src/store/use-editor.tsx | 119 ++++-- packages/editor/src/store/use-facing-pose.ts | 44 +++ packages/nodes/src/column/definition.ts | 1 + packages/nodes/src/column/tool.tsx | 22 +- packages/nodes/src/door/definition.ts | 1 + packages/nodes/src/door/tool.tsx | 27 +- packages/nodes/src/fence/tool.tsx | 4 +- packages/nodes/src/item/definition.ts | 1 + packages/nodes/src/item/tool.tsx | 5 +- packages/nodes/src/shelf/definition.ts | 1 + packages/nodes/src/shelf/tool.tsx | 11 +- packages/nodes/src/stair/definition.ts | 4 + packages/nodes/src/wall/tool.tsx | 3 +- packages/nodes/src/window/definition.ts | 1 + packages/nodes/src/window/tool.tsx | 27 +- wiki/architecture/interaction-scope.md | 8 +- 47 files changed, 1285 insertions(+), 505 deletions(-) create mode 100644 packages/editor/src/components/tools/shared/facing-indicator.tsx create mode 100644 packages/editor/src/components/tools/shared/facing-pose-indicator.tsx create mode 100644 packages/editor/src/lib/active-placement-surface.ts create mode 100644 packages/editor/src/lib/continuation.ts create mode 100644 packages/editor/src/store/use-facing-pose.ts diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 840c0946..47b43a90 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -30,6 +30,7 @@ export { nodeRegistry, type PluginDiscovery, registerNode, + resolveFacingIndicator, setPluginDiscovery, } from './registry' export { diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 57aaabcf..2455c967 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -178,6 +178,18 @@ export function isPresettableKind(kind: string): boolean { 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 `` + * 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`, * `wallT`, etc.). Read by host apps at preset-save time to strip these diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index d3133c62..4ea592f8 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -732,6 +732,14 @@ export type NodeDefinition> = { schema: S category: NodeCategory 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 / * refrigerant lineset). Lets the system-graph summary classify a diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 4d569116..35b66986 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -1153,12 +1153,12 @@ export const CustomCameraControls = () => { }, []) // Preset capture mode frames a single subtree (often a 0.3–2m 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 // capturing presets; reset on exit so general editing keeps the looser // navigation guardrails. const isPresetCapture = captureMode.mode === 'preset' - const minDistance = isPresetCapture ? 0.5 : 6 + const minDistance = isPresetCapture ? 0.5 : 2 if (isFirstPersonMode) { return null diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index a177a01c..6c93d7b5 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -8,6 +8,7 @@ import { DEFAULT_WALL_HEIGHT, DoorNode, ElevatorNode, + emitter, FenceNode, generateId, getActiveRoofHeight, @@ -215,6 +216,7 @@ export function FloatingActionMenu() { const updateNode = useScene((s) => s.updateNode) const mode = useEditor((s) => s.mode) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) + const canFindNode = useEditor((s) => s.canFindNode) const endpointReshape = useEndpointReshape() const isCurveReshape = useIsCurveReshape() const setMovingNode = useEditor((s) => s.setMovingNode) @@ -655,6 +657,16 @@ export function FloatingActionMenu() { [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 ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || endpointReshape || @@ -676,6 +688,7 @@ export function FloatingActionMenu() { >
uniform(revealRadius), [revealRadius]) + const baseAlphaUniform = useMemo(() => uniform(0.4), []) + const cellSizeUniform = useMemo(() => uniform(cellSize), [cellSize]) + const patchAlphaUniform = useMemo(() => uniform(1), []) const material = useMemo(() => { // 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 // 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 fw = fwidth(r) // Distance to nearest grid line for each axis @@ -70,7 +94,7 @@ export const Grid = ({ return lineX.max(lineY) } - const g1 = getGrid(cellSize, cellThickness) + const g1 = getGrid(cellSizeUniform, cellThickness) const g2 = getGrid(sectionSize, sectionThickness) // Distance fade from center @@ -79,7 +103,9 @@ export const Grid = ({ // Cursor reveal effect - distance from cursor 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 const gridColor = mix( @@ -88,12 +114,10 @@ export const Grid = ({ 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 - const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha)) - const finalAlpha = mix(alpha.mul(0.75), alpha, g2) + const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlphaUniform)) + const boostedAlpha = alpha.mul(patchAlphaUniform).min(1) + const finalAlpha = mix(boostedAlpha.mul(0.75), boostedAlpha, g2) return new MeshBasicNodeMaterial({ transparent: true, @@ -102,7 +126,6 @@ export const Grid = ({ depthWrite: false, }) }, [ - cellSize, cellThickness, effectiveCellColor, sectionSize, @@ -110,7 +133,10 @@ export const Grid = ({ effectiveSectionColor, fadeDistance, fadeStrength, - revealRadius, + revealRadiusUniform, + baseAlphaUniform, + cellSizeUniform, + patchAlphaUniform, ]) const gridRef = useRef(null!) @@ -145,30 +171,80 @@ export const Grid = ({ useFrame((_, delta) => { const { levelId } = useViewer.getState().selection - // Grid stays anchored to world XZ (0, 0) — never chases the active - // 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 + let levelY = 0 if (levelId) { const levelMesh = sceneRegistry.nodes.get(levelId) 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 - // is just the world cursor (mirrored on Z to match the -π/2 X-rotation - // of the plane). - const world = lastWorldCursorRef.current - if (world) { - cursorPositionRef.current.set(world.x, -world.z) + // Resolve the surface the active ghost is snapped to (contact point + + // normal). A fresh GLB item / drawn kind publishes via the surface module; a + // 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 showGrid = useViewer((state) => state.showGrid) + 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 + if (world) { + cursorPositionRef.current.set(world.x, -world.z) + } + setGridY(newY) + } + + // 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 // is never reconciled with R3F's empty placeholder `BufferGeometry`. @@ -183,13 +259,8 @@ export const Grid = ({ useEffect(() => () => geometry.dispose(), [geometry]) return ( - + // Orientation is driven imperatively in `useFrame` (horizontal by default, + // tilted into the wall plane while placing on a wall), so no static rotation. + ) } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d8b27dec..8ced34af 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -2,6 +2,9 @@ import { Icon } from '@iconify/react' import { + getCatalogMaterialById, + getLibraryMaterialIdFromRef, + getSceneMaterialIdFromRef, initSpaceDetectionSync, initSpatialGridSync, spatialGridManager, @@ -19,6 +22,7 @@ import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' +import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint' import { applySceneGraphToEditor, loadSceneFromLocalStorage, @@ -81,6 +85,7 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8' const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14 const SCENE_READY_FALLBACK_MS = 8000 +type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked' const EDITOR_HOVER_STYLES: HoverStyles = { 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 }, @@ -535,14 +540,70 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } }) ) } +function getActivePaintMaterialSwatchColor( + material: ActivePaintMaterial | null, + sceneMaterials: ReturnType['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['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({ position, - disabled, + state, + swatchColor, + swatchImageUrl, + isEraser, }: { 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 return ( @@ -576,7 +637,48 @@ function PaintCursorBadge({ aria-hidden="true" className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]" src="/icons/paint.webp" + style={{ + filter: state === 'ready' ? undefined : 'grayscale(1)', + opacity: iconOpacity, + }} /> + {state === 'ready' ? ( + isEraser ? ( + + + ) : ( + + ) + ) : state === 'blocked' ? ( + + + ) : ( + + ? + + )}
) @@ -730,6 +832,9 @@ function PaintCursorLayer({ }) { const mode = useEditor((s) => s.mode) 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 active = mode === 'material-paint' && !isVersionPreviewMode @@ -779,11 +884,14 @@ function PaintCursorLayer({ } }, [active, containerRef]) - const hasMaterial = Boolean( - activePaintMaterial && - (activePaintMaterial.material !== undefined || - activePaintMaterial.materialPreset !== undefined), - ) + const hasPaint = paintEraser || hasActivePaintMaterial(activePaintMaterial) + const badgeState: PaintCursorBadgeState = !hasPaint + ? 'empty' + : paintHover != null + ? 'ready' + : 'blocked' + const swatchColor = getActivePaintMaterialSwatchColor(activePaintMaterial, sceneMaterials) + const swatchImageUrl = getActivePaintMaterialSwatchImageUrl(activePaintMaterial, sceneMaterials) if (!active || !position) return null @@ -792,7 +900,13 @@ function PaintCursorLayer({ className="pointer-events-none absolute z-40" style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }} > - + ) } diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index 95b86dd1..39f22485 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -1,10 +1,11 @@ 'use client' 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' type NodeActionMenuProps = { + onFind?: MouseEventHandler onAddHole?: MouseEventHandler onDelete?: MouseEventHandler onDuplicate?: MouseEventHandler @@ -17,6 +18,7 @@ type NodeActionMenuProps = { } export function NodeActionMenu({ + onFind, onAddHole, onDelete, onDuplicate, @@ -35,6 +37,17 @@ export function NodeActionMenu({ onPointerLeave={onPointerLeave} onPointerUp={onPointerUp} > + {onFind && ( + + )} {onMove && ( + ) + + if (!tooltip) return button + return ( + + {button} + {tooltip} + + ) +} + const SNAPPING_MODE_ICONS = { grid: 'lucide:grid-2x2', lines: 'lucide:magnet', @@ -48,26 +122,6 @@ const SNAPPING_MODE_LABELS = { off: 'Off', } as const -const WALL_CHAIN_MODE_ICONS: Record = { - room: 'lucide:square', - single: 'lucide:minus', -} - -const WALL_CHAIN_MODE_LABELS: Record = { - room: 'Room (auto-close)', - single: 'Single wall', -} - -const FENCE_CHAIN_MODE_ICONS: Record = { - continuous: 'lucide:waypoints', - single: 'lucide:minus', -} - -const FENCE_CHAIN_MODE_LABELS: Record = { - continuous: 'Continuous', - single: 'Single fence', -} - const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] 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]! } -// Interactive chip rows: the active interaction's own snapping controls, scoped -// to its context (wall / item / polygon) so each action shows only the modes -// 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. +// The active interaction's snapping controls, scoped to its context (wall / item +// / polygon) so each action shows only the modes that make sense for it. function SnappingChips({ context }: { context: SnapContext }) { const snappingMode = useEditor((s) => s.snappingModeByContext[context]) const setSnappingMode = useEditor((s) => s.setSnappingMode) @@ -89,118 +141,43 @@ function SnappingChips({ context }: { context: SnapContext }) { return ( <> - - - - - Snapping mode — click or press Shift to cycle - - + setSnappingMode(context, cycleSnappingModeIn(context, snappingMode))} + shortcut="Shift" + tooltip="Snapping mode — click or press Shift to cycle" + /> {gridActive ? ( - - - - - Grid step — click or tap Ctrl to cycle - + setGridSnapStep(nextGridSnapStep(gridSnapStep))} + shortcut="Ctrl" + tooltip="Grid step — click or tap Ctrl to cycle" + /> ) : null} ) } -function nextWallChainMode(mode: WallChainMode): WallChainMode { - return mode === 'room' ? 'single' : 'room' -} - -function WallChainModeChip() { - const wallChainMode = useEditor((s) => s.wallChainMode) - const setWallChainMode = useEditor((s) => s.setWallChainMode) - const label = WALL_CHAIN_MODE_LABELS[wallChainMode] +function ContinuationChip({ context }: { context: ContinuationContext }) { + const mode = useEditor((s) => s.getContinuation(context)) + const cycleContinuation = useEditor((s) => s.cycleContinuation) + const profile = CONTINUATION_PROFILES[context] + const label = profile.labels[mode] ?? mode + const icon = profile.icons[mode] ?? 'lucide:repeat' return ( - - - - - Wall drafting mode - click or tap Alt to cycle - - ) -} - -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 ( - - - - - Fence drafting mode - click or tap Alt to cycle - + cycleContinuation(context)} + shortcut="C" + tooltip="Continuation — click or press C to cycle" + /> ) } @@ -213,7 +190,7 @@ const PAINT_SCOPE_ICONS: Record = { // 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 -// per-target table. Carves out `pointer-events-auto` like the snapping chips. +// per-target table. function PaintScopeChip() { // 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. @@ -226,26 +203,13 @@ function PaintScopeChip() { // 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. if (!(paintEraser || hasActivePaintMaterial(activePaintMaterial))) { - return ( -
- - - Select a material to paint - -
- ) + return } // Not over anything paintable → guide the user to hover, still teaching Shift. if (!paintHover) { return ( -
- - - Hover a surface to paint - - -
+ ) } @@ -255,36 +219,25 @@ function PaintScopeChip() { const effective: PaintScope = scopes.includes(paintScope) ? paintScope : 'single' // 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) { return ( -
- - - Paint: {paintScopeLabel(effective, paintHover)} - -
+ ) } return ( - - - - - Paint scope — click or press Shift to cycle - + cyclePaintScope()} + shortcut="Shift" + tooltip="Paint scope — click or press Shift to cycle" + /> ) } @@ -292,43 +245,44 @@ export function ContextualHelperPanel({ hints, snapContext = null, showPaintScope = false, - showWallChainMode = false, - showFenceChainMode = false, + continuationContext = null, }: { hints: ContextualShortcutHint[] // The active snapping context drives the snapping chips (which mode set). Null // → no snapping chips for this interaction. snapContext?: SnapContext | null showPaintScope?: boolean - showWallChainMode?: boolean - showFenceChainMode?: boolean + continuationContext?: ContinuationContext | null }) { - if ( - hints.length === 0 && - !snapContext && - !showPaintScope && - !showWallChainMode && - !showFenceChainMode - ) + if (hints.length === 0 && !snapContext && !showPaintScope && !continuationContext) return null return ( -
+
{snapContext ? : null} - {showWallChainMode ? : null} - {showFenceChainMode ? : null} + {continuationContext ? : null} {showPaintScope ? : null} {hints.map((hint) => (
- {hint.label} +
+
+ {hint.label} +
+ {hint.subtitle ? ( +
+ {hint.subtitle} +
+ ) : null} +
))}
diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index f7c34a13..ebc18ffd 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -16,10 +16,12 @@ import { resolveRotateHandleHelpHints, resolveSelectModeHelpHints, } from '../../../lib/contextual-help' +import { continuationContextOf } from '../../../lib/continuation' import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation' import type { ReshapeKind } from '../../../lib/interaction/scope' +import { isFreshPlacementMetadata } from '../../../lib/placement-metadata' import { snapContextOf } from '../../../lib/snapping-mode' -import useEditor from '../../../store/use-editor' +import useEditor, { getActiveContinuationContext } from '../../../store/use-editor' import useInteractionScope, { useActiveHandleDrag, useMovingNode, @@ -113,6 +115,10 @@ export function HelperManager() { }), [scope, mode, tool], ) + const continuationContext = useMemo( + () => getActiveContinuationContext(), + [scope, mode, tool], + ) const selectModeHints = useMemo( () => resolveSelectModeHelpHints({ @@ -144,10 +150,17 @@ export function HelperManager() { if (movingNode) { if (movingNode.type === 'building') return + // 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; // structural kinds (wall/slab/…) never reject, so don't advertise Alt. return ( 0) { return ( diff --git a/packages/editor/src/components/ui/helpers/item-helper.tsx b/packages/editor/src/components/ui/helpers/item-helper.tsx index 9ebe284a..e20947b8 100644 --- a/packages/editor/src/components/ui/helpers/item-helper.tsx +++ b/packages/editor/src/components/ui/helpers/item-helper.tsx @@ -1,3 +1,4 @@ +import type { ContinuationContext } from '../../../lib/continuation' import type { SnapContext } from '../../../lib/snapping-mode' import { ContextualHelperPanel } from './contextual-helper-panel' @@ -7,13 +8,22 @@ interface ItemHelperProps { // Whether to advertise Alt = force-place. Only meaningful for kinds that // collision-validate their drop (structural kinds never reject, so it's hidden). 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 // 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 ( = hint.minDraftVertices), ) - if (visible.length === 0 && !snapContext && !showWallChainMode && !showFenceChainMode) - return null + if (visible.length === 0 && !snapContext && !continuationContext) return null return ( { @@ -50,9 +48,8 @@ export function RegisteredToolHelper({ active: shiftPressed && isBypassHint, } })} + continuationContext={continuationContext} snapContext={snapContext} - showWallChainMode={showWallChainMode} - showFenceChainMode={showFenceChainMode} /> ) } diff --git a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx index a2d215d5..f37f9cac 100644 --- a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx +++ b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx @@ -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) { return <>{emptyState} } diff --git a/packages/editor/src/components/ui/primitives/shortcut-token.tsx b/packages/editor/src/components/ui/primitives/shortcut-token.tsx index 9878daab..42352720 100644 --- a/packages/editor/src/components/ui/primitives/shortcut-token.tsx +++ b/packages/editor/src/components/ui/primitives/shortcut-token.tsx @@ -22,6 +22,17 @@ const MOUSE_SHORTCUTS = { }, } 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'> & { value: string displayValue?: string @@ -30,16 +41,19 @@ type ShortcutTokenProps = React.ComponentProps<'kbd'> & { function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) { const mouseShortcut = 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 ( {mouseShortcut ? ( @@ -54,6 +68,10 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok /> {mouseShortcut.label} + ) : 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. + {commandDisplay} ) : ( (displayValue ?? value) )} diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 912b4625..f9f46b8e 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -10,7 +10,7 @@ import { } from '../lib/scene-clipboard' import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus' 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' // 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, // every node move (including wall-hosted items + door/window openings, which // 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 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 // between) cycles the grid step — same context as the Shift snapping-mode // 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 / // Ctrl+C never cycle. let ctrlTapClean = false - let altTapClean = false const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Control' || e.key === 'Meta') { // Only a fresh, modifier-free press starts a clean-tap candidate; // ignore key-repeat and presses already part of a combo. 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 { // Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks // the clean tap. ctrlTapClean = false - altTapClean = false } // Don't handle shortcuts if user is typing in an input @@ -110,6 +92,23 @@ export const useKeyboard = ({ 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') { e.preventDefault() _toolCancelConsumed = false @@ -434,8 +433,8 @@ export const useKeyboard = ({ const wasClean = ctrlTapClean ctrlTapClean = false if (!wasClean) return - // Same scope as the Shift snapping-mode cycle: wall / fence build only, - // and never while typing in an input. + // Same scope as the Shift snapping-mode cycle, and never while typing + // in an input. if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return } @@ -445,22 +444,6 @@ export const useKeyboard = ({ sfxEmitter.emit('sfx:grid-snap') 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) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 65049822..daffb054 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -88,6 +88,7 @@ export { } from './components/tools/item/use-placement-coordinator' export { CursorSphere } from './components/tools/shared/cursor-sphere' export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' +export { FacingIndicator } from './components/tools/shared/facing-indicator' export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. @@ -219,6 +220,13 @@ export { } from './lib/ceiling-plan-snap' export { EDITOR_LAYER } from './lib/constants' // Helper libs used by the kind-owned roof / stair / elevator panels. +export { + CONTINUATION_PROFILES, + type ContinuationContext, + type ContinuationMode, + continuationContextOf, + nextContinuation, +} from './lib/continuation' export { resolveCurrentBuildingId, resolveElevatorNodeSupportY, @@ -335,10 +343,13 @@ export type { } from './store/use-editor' export { default as useEditor, + getActiveContinuationContext, + getContinuation, isAngleSnapActive, isGridSnapActive, isMagneticSnapActive, } from './store/use-editor' +export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useInteractionScope, getEditingHole, diff --git a/packages/editor/src/lib/active-placement-surface.ts b/packages/editor/src/lib/active-placement-surface.ts new file mode 100644 index 00000000..c9cf9a3c --- /dev/null +++ b/packages/editor/src/lib/active-placement-surface.ts @@ -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 +} diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index 0e9023ae..2019849e 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -1,6 +1,9 @@ export type ContextualShortcutHint = { keys: 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 } diff --git a/packages/editor/src/lib/continuation.ts b/packages/editor/src/lib/continuation.ts new file mode 100644 index 00000000..1a34edbe --- /dev/null +++ b/packages/editor/src/lib/continuation.ts @@ -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 + icons: Record + } +> = { + 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 +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 008019f1..87cdbbe6 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -34,6 +34,13 @@ import { import { useViewer } from '@pascal-app/viewer' import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { + CONTINUATION_PROFILES, + type ContinuationContext, + type ContinuationMode, + continuationContextOf, + nextContinuation, +} from '../lib/continuation' import { type ActivePaintMaterial, type PaintableMaterialTarget, @@ -152,8 +159,6 @@ export type StructureLayer = 'zones' | 'elements' export type FloorplanSelectionTool = 'click' | 'marquee' 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' @@ -312,6 +317,12 @@ type EditorState = { // surface" hint). Set by the selection-manager paint hover; not persisted. paintHover: PaintHoverInfo | null 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 setSelectedReferenceId: (id: string | null) => void guideUi: Record @@ -375,12 +386,10 @@ type EditorState = { setSnappingMode: (context: SnapContext, mode: SnappingMode) => void // Cycle the *active* context's mode within its own set; returns the new value. cycleSnappingMode: () => SnappingMode - wallChainMode: WallChainMode - setWallChainMode: (mode: WallChainMode) => void - cycleWallChainMode: () => WallChainMode - fenceChainMode: FenceChainMode - setFenceChainMode: (mode: FenceChainMode) => void - cycleFenceChainMode: () => FenceChainMode + continuationByContext: Record + setContinuation: (context: ContinuationContext, mode: ContinuationMode) => void + cycleContinuation: (context: ContinuationContext) => ContinuationMode + getContinuation: (context: ContinuationContext) => ContinuationMode showReferenceFloor: boolean toggleReferenceFloor: () => void setShowReferenceFloor: (show: boolean) => void @@ -430,8 +439,7 @@ type PersistedEditorLayoutState = Pick< | 'gridSnapStep' | 'magneticSnap' | 'snappingModeByContext' - | 'wallChainMode' - | 'fenceChainMode' + | 'continuationByContext' | 'showReferenceFloor' | 'referenceFloorOffset' | 'referenceFloorOpacity' @@ -460,8 +468,11 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = item: defaultSnappingModeFor('item'), polygon: defaultSnappingModeFor('polygon'), }, - wallChainMode: 'room', - fenceChainMode: 'continuous', + continuationByContext: { + wall: CONTINUATION_PROFILES.wall.default, + fence: CONTINUATION_PROFILES.fence.default, + point: CONTINUATION_PROFILES.point.default, + }, showReferenceFloor: false, referenceFloorOffset: 1, referenceFloorOpacity: 0.35, @@ -572,16 +583,40 @@ function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode : defaultSnappingModeFor(context) } -function migrateWallChainMode(value: unknown): WallChainMode { - return value === 'single' || value === 'room' ? value : 'room' +type LegacyContinuationState = { + continuationByContext?: Partial> + wallChainMode?: unknown + fenceChainMode?: unknown } -function migrateFenceChainMode(value: unknown): FenceChainMode { - return value === 'single' || value === 'continuous' ? value : 'continuous' +function migrateContinuationMode( + 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 { + 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( - state: Partial | null | undefined, + state: (Partial & LegacyContinuationState) | null | undefined, ): PersistedEditorLayoutState { return { activeSidebarPanel: @@ -601,8 +636,7 @@ function normalizePersistedEditorLayoutState( item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'), polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'), }, - wallChainMode: migrateWallChainMode(state?.wallChainMode), - fenceChainMode: migrateFenceChainMode(state?.fenceChainMode), + continuationByContext: normalizeContinuationByContext(state), showReferenceFloor: state?.showReferenceFloor === true, referenceFloorOffset: typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 @@ -932,6 +966,8 @@ const useEditor = create()( }, paintHover: null, setPaintHover: (info) => set({ paintHover: info }), + canFindNode: false, + setCanFindNode: (canFind) => set({ canFindNode: canFind }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), guideUi: {}, @@ -1070,19 +1106,24 @@ const useEditor = create()( })) return next }, - wallChainMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.wallChainMode, - setWallChainMode: (mode) => set({ wallChainMode: mode }), - cycleWallChainMode: () => { - const next = get().wallChainMode === 'room' ? 'single' : 'room' - set({ wallChainMode: next }) + continuationByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.continuationByContext, + setContinuation: (context, mode) => { + const next = + migrateContinuationMode(mode, context) ?? CONTINUATION_PROFILES[context].default + 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 }, - fenceChainMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.fenceChainMode, - setFenceChainMode: (mode) => set({ fenceChainMode: mode }), - cycleFenceChainMode: () => { - const next = get().fenceChainMode === 'continuous' ? 'single' : 'continuous' - set({ fenceChainMode: next }) - return next + getContinuation: (context) => { + const current = get().continuationByContext[context] + return migrateContinuationMode(current, context) ?? CONTINUATION_PROFILES[context].default }, showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, toggleReferenceFloor: () => @@ -1177,8 +1218,7 @@ const useEditor = create()( gridSnapStep: state.gridSnapStep, magneticSnap: state.magneticSnap, snappingModeByContext: state.snappingModeByContext, - wallChainMode: state.wallChainMode, - fenceChainMode: state.fenceChainMode, + continuationByContext: state.continuationByContext, showReferenceFloor: state.showReferenceFloor, referenceFloorOffset: state.referenceFloorOffset, 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 * default (free) when no snappable context is active, so a stray reader never diff --git a/packages/editor/src/store/use-facing-pose.ts b/packages/editor/src/store/use-facing-pose.ts new file mode 100644 index 00000000..29d7977a --- /dev/null +++ b/packages/editor/src/store/use-facing-pose.ts @@ -0,0 +1,44 @@ +// Ephemeral store for the forward-facing floor triangle shown while placing or +// moving a node. A single editor-side overlay (``) +// 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((set) => ({ + pose: null, + set: (pose) => set({ pose }), + clear: () => set({ pose: null }), +})) + +export default useFacingPose diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index c6c8fd78..b74a2dad 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -315,6 +315,7 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor[] export const columnDefinition: NodeDefinition = { kind: 'column', snapProfile: 'item', + facingIndicator: true, schemaVersion: 1, schema: ColumnNode, category: 'structure', diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 630e0e20..fa4462be 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -16,6 +16,7 @@ import { triggerSFX, useAlignmentGuides, useEditor, + useFacingPose, usePlacementPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' @@ -101,6 +102,13 @@ const ColumnTool = () => { levelId: activeLevelId, }) cursorRef.current?.position.set(...visualPosition) + // Forward-facing floor triangle, drawn by the editor-side overlay. Columns + // never rotate (`rotation: 0`), so the triangle just sits in front. + useFacingPose.getState().set({ + position: visualPosition, + rotationY: previewNode.rotation, + depth: previewNode.depth, + }) lastCursorRef.current = position // Publish a transient, positioned preview node for the 2D floor-plan @@ -130,12 +138,17 @@ const ColumnTool = () => { useScene.getState().createNode(column, activeLevelId) useViewer.getState().setSelection({ selectedIds: [column.id] }) triggerSFX('sfx:structure-build') - // The placed column is now a valid alignment target for the next one; - // refresh the candidate pool and drop the guide from this drop. The - // 2D ghost re-publishes on the next move. - alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) useAlignmentGuides.getState().clear() usePlacementPreview.getState().clear() + if (useEditor.getState().getContinuation('point') === 'repeat') { + // The placed column is now a valid alignment target for the next one. + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + } else { + cursorVisibleRef.current = false + setCursorVisible(false) + useFacingPose.getState().clear() + useEditor.getState().setTool(null) + } stopPlacementCommitPropagation(event) } @@ -147,6 +160,7 @@ const ColumnTool = () => { unsubscribePlacementClicks() useAlignmentGuides.getState().clear() usePlacementPreview.getState().clear() + useFacingPose.getState().clear() } }, [activeLevelId, previewNode]) diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index d2c65e38..0f97d0cc 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -167,6 +167,7 @@ const doorHandles: HandleDescriptor[] = [ export const doorDefinition: NodeDefinition = { kind: 'door', snapProfile: 'item', + facingIndicator: true, schemaVersion: 1, schema: DoorNode, category: 'structure', diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 6e6b6e17..8a666612 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -16,11 +16,13 @@ import { calculateCursorRotation, calculateItemRotation, EDITOR_LAYER, + FacingIndicator, getSideFromNormal, isMagneticSnapActive, isValidWallSideFace, triggerSFX, useAlignmentGuides, + useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -73,6 +75,7 @@ type HostKind = 'wall' | 'roof' | null const DoorTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) + const indicatorYOffsetRef = useRef(null!) const edgesRef = useRef(null!) // Off-host floating ghost: the real door geometry follows the cursor over @@ -155,6 +158,7 @@ const DoorTool: React.FC = () => { worldPosition: [number, number, number], cursorRotationY: number, valid: boolean, + indicatorYOffset: number, ) => { setFallbackPose(null) const group = cursorGroupRef.current @@ -162,6 +166,7 @@ const DoorTool: React.FC = () => { group.visible = true group.position.set(...worldPosition) group.rotation.y = cursorRotationY + indicatorYOffsetRef.current?.position.set(0, indicatorYOffset, 0) edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } @@ -282,6 +287,7 @@ const DoorTool: React.FC = () => { ), cursorRotationY, valid, + -clampedY, ) if (draftRef.current) { @@ -358,11 +364,16 @@ const DoorTool: React.FC = () => { useScene.getState().createNode(node, wall.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) - useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') - alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() clearOpeningGuides3D() + if (useEditor.getState().getContinuation('point') === 'repeat') { + useScene.temporal.getState().pause() + alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '') + } else { + hideCursor() + useEditor.getState().setTool(null) + } } // ── Direct wall-mesh hover ────────────────────────────────────── @@ -469,7 +480,7 @@ const DoorTool: React.FC = () => { const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { const pose = getRoofWallOpeningCursorPose(target, roof) - if (pose) updateCursor(pose.position, pose.rotationY, target.valid) + if (pose) updateCursor(pose.position, pose.rotationY, target.valid, -target.position[1]) } const onRoofHover = (event: RoofEvent) => { @@ -568,8 +579,13 @@ const DoorTool: React.FC = () => { // picks up the new opening cut. useScene.getState().dirtyNodes.add(segment.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) - useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') + if (useEditor.getState().getContinuation('point') === 'repeat') { + useScene.temporal.getState().pause() + } else { + hideCursor() + useEditor.getState().setTool(null) + } event.stopPropagation() } @@ -661,6 +677,9 @@ const DoorTool: React.FC = () => { material={edgeMaterial} ref={edgesRef} /> + + + {fallbackPose && ( diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index a6051540..6356ac83 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -500,7 +500,7 @@ export const FenceTool: React.FC = () => { // While drafting, the segment locks to 15° rays from its start. // Snapping is governed by the snapping mode (`'off'` is the bypass); // there is no Shift hold-to-bypass. Alignment follows the magnetic snap - // mode, not Alt (Alt-tap toggles continuous/single chaining). + // mode, not Alt (continuation is cycled through the HUD / C). const bypassAlign = !isMagneticSnapActive() if (buildingState.current === 1) { @@ -614,7 +614,7 @@ export const FenceTool: React.FC = () => { // Single mode commits one segment per click: stop drafting so the next // click starts a fresh segment instead of chaining off this endpoint. - if (useEditor.getState().fenceChainMode === 'single') { + if (useEditor.getState().getContinuation('fence') === 'single') { stopDrafting() return } diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index fae9bb17..5897b373 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -167,6 +167,7 @@ function itemWallMoveHandle(): HandleDescriptor { export const itemDefinition: NodeDefinition = { kind: 'item', snapProfile: 'item', + facingIndicator: true, schemaVersion: 1, schema: ItemNode, category: 'furnish', diff --git a/packages/nodes/src/item/tool.tsx b/packages/nodes/src/item/tool.tsx index 9f882a56..2b86a14e 100644 --- a/packages/nodes/src/item/tool.tsx +++ b/packages/nodes/src/item/tool.tsx @@ -35,10 +35,7 @@ function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) { }, onCommitted: () => { triggerSFX('sfx:item-place') - // Returning `true` tells the coordinator to immediately spawn the - // next draft so the user can keep placing copies — matches the - // "repeat-on-click" UX of the legacy tool. - return true + return useEditor.getState().getContinuation('point') === 'repeat' }, }) diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index a4725f74..d583a6cd 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -133,6 +133,7 @@ function shelfHandles(_node: ShelfNodeType): HandleDescriptor[] { export const shelfDefinition: NodeDefinition = { kind: 'shelf', snapProfile: 'item', + facingIndicator: true, schemaVersion: 2, schema: ShelfNode, category: 'furnish', diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index ea235c79..99103956 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -128,10 +128,15 @@ const ShelfTool = () => { useScene.getState().createNode(shelf, activeLevelId) useViewer.getState().setSelection({ selectedIds: [shelf.id] }) triggerSFX('sfx:item-place') - // The placed shelf is now a valid alignment target for the next one; - // refresh the candidate pool and drop the guide from this drop. - alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) useAlignmentGuides.getState().clear() + if (useEditor.getState().getContinuation('point') === 'repeat') { + // The placed shelf is now a valid alignment target for the next one. + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + } else { + cursorVisibleRef.current = false + setCursorVisible(false) + useEditor.getState().setTool(null) + } stopPlacementCommitPropagation(event) } diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index c12268ad..d5431ceb 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -422,6 +422,10 @@ export const stairDefinition: NodeDefinition = { schema: StairNode, category: 'structure', snapProfile: 'structural', + // A footprint with a clear front: you approach a stair from the low end, + // which sits on the -Z side of the run (the run ascends along +Z). Show the + // floor facing triangle there, pointing out of the entry, while placing/moving. + facingIndicator: { reversed: true }, // Placed as a footprint (R/T rotates), not a directional draw → no angle-lock // mode. The toolHints presence routes it through the contextual HUD so the // snapping chip shows during placement. diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index b202cf82..3f57359e 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -705,8 +705,7 @@ export const WallTool: React.FC = () => { useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() - const wallChainMode = useEditor.getState().wallChainMode - if (wallChainMode === 'single') { + if (useEditor.getState().getContinuation('wall') === 'single') { stopDrafting() return } diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index da5a4bd7..b479ed2d 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -161,6 +161,7 @@ const windowHandles: HandleDescriptor[] = [ export const windowDefinition: NodeDefinition = { kind: 'window', snapProfile: 'item', + facingIndicator: true, schemaVersion: 1, schema: WindowNode, category: 'structure', diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index e37aab09..e7e77b00 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -16,12 +16,14 @@ import { calculateCursorRotation, calculateItemRotation, EDITOR_LAYER, + FacingIndicator, getSideFromNormal, isMagneticSnapActive, isValidWallSideFace, snapToHalf, triggerSFX, useAlignmentGuides, + useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -86,6 +88,7 @@ type HostKind = 'wall' | 'roof' | null const WindowTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) + const indicatorYOffsetRef = useRef(null!) const edgesRef = useRef(null!) // Off-host floating ghost: the real window geometry follows the cursor @@ -169,6 +172,7 @@ const WindowTool: React.FC = () => { worldPosition: [number, number, number], cursorRotationY: number, valid: boolean, + indicatorYOffset: number, ) => { setFallbackPose(null) const group = cursorGroupRef.current @@ -176,6 +180,7 @@ const WindowTool: React.FC = () => { group.visible = true group.position.set(...worldPosition) group.rotation.y = cursorRotationY + indicatorYOffsetRef.current?.position.set(0, indicatorYOffset, 0) edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } @@ -341,6 +346,7 @@ const WindowTool: React.FC = () => { ), cursorRotationY, valid, + -clampedY, ) if (draftRef.current) { @@ -411,11 +417,16 @@ const WindowTool: React.FC = () => { useScene.getState().createNode(node, wall.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) - useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') - alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() clearOpeningGuides3D() + if (useEditor.getState().getContinuation('point') === 'repeat') { + useScene.temporal.getState().pause() + alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '') + } else { + hideCursor() + useEditor.getState().setTool(null) + } } // ── Direct wall-mesh hover ────────────────────────────────────── @@ -531,7 +542,7 @@ const WindowTool: React.FC = () => { const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => { const pose = getRoofWallOpeningCursorPose(target, roof) - if (pose) updateCursor(pose.position, pose.rotationY, target.valid) + if (pose) updateCursor(pose.position, pose.rotationY, target.valid, -target.position[1]) } const onRoofHover = (event: RoofEvent) => { @@ -624,8 +635,13 @@ const WindowTool: React.FC = () => { // picks up the new opening cut. useScene.getState().dirtyNodes.add(segment.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) - useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') + if (useEditor.getState().getContinuation('point') === 'repeat') { + useScene.temporal.getState().pause() + } else { + hideCursor() + useEditor.getState().setTool(null) + } event.stopPropagation() } @@ -714,6 +730,9 @@ const WindowTool: React.FC = () => { material={edgeMaterial} ref={edgesRef} /> + + + {fallbackPose && ( diff --git a/wiki/architecture/interaction-scope.md b/wiki/architecture/interaction-scope.md index 8a07f72c..61914d63 100644 --- a/wiki/architecture/interaction-scope.md +++ b/wiki/architecture/interaction-scope.md @@ -131,11 +131,9 @@ There is no per-kind snapping switch. 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; 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 - tools have no force role (a wall/fence always places), so a clean **Alt-tap** cycles the chain mode - (`wallChainMode` room/single, `fenceChainMode` continuous/single) — wired in `hooks/use-keyboard.ts` via - `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. + Shift is **not** a snap bypass. Alt is **not** a snap toggle. Placement continuation (wall room/single, + fence continuous/single, point once/repeat) is a separate per-context mode, cycled by **C** and surfaced as + a clickable HUD chip. - **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 (a build tool, `drafting`, `placing`/`moving`, or `reshaping`).