feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf

Implements plans/editor-placement-interaction-overhaul.md: an authoritative
interaction-scope state machine plus the catalogued placement/interaction
fixes, and split-view floor-plan performance.

- Interaction-scope spine (lib/interaction/* + store/use-interaction-scope),
  driven from central useEditor setters; overlay scoping (zone labels,
  context badges, floating action menu) reads resolveOverlayPolicy.
- Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity,
  auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset,
  per-frame jank.
- Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier
  model (Shift=cycle, Alt=free place, Ctrl=grid step).
- Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle
  rig hides during a whole-node move; rotate gizmo advertises Shift=free
  rotation in the HUD and hides the move cross while rotating.
- Floor-plan perf: pause live reactivity while in 3D-only view; per-node
  geometry cache so only changed nodes rebuild on a drag; hoist wall miters
  to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-23 09:04:58 -04:00
co-authored by Claude Opus 4.8
parent b2f1a8432e
commit f773e6b8c5
71 changed files with 2362 additions and 598 deletions
+35 -11
View File
@@ -4,7 +4,7 @@ import { nodeRegistry } from '@pascal-app/core'
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes' import { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image' import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -152,15 +152,19 @@ function activateRoofFeatureTool(kind: string): void {
* with the kind's own `def.defaults()`. The "Painting" type swaps in the * with the kind's own `def.defaults()`. The "Painting" type swaps in the
* material-paint panel. * material-paint panel.
*/ */
// MEP tool kinds that, when active, mean the MEP group tile (and its sub-grid)
// is what the user is working in.
const MEP_TOOL_KINDS = new Set<string>([
...MEP_ITEMS.map((item) => item.kind),
'duct-fitting',
'pipe-fitting',
])
export function BuildTab() { export function BuildTab() {
const activeTool = useEditor((s) => s.tool) const activeTool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow) const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
// Which build tile's panel is showing. Roof (Features) and MEP (its tool
// sub-grid) are the tiles with a panel; others arm a tool and show nothing
// below.
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null)
// The fitting / follow tools are armed from a segment's panel, not a grid // The fitting / follow tools are armed from a segment's panel, not a grid
// tile — keep the segment tile lit so the panel (and the way back) stays // tile — keep the segment tile lit so the panel (and the way back) stays
@@ -200,8 +204,23 @@ export function BuildTab() {
return features return features
}, []) }, [])
const isTypeActive = (type: BuildType) => // Tile highlight derives from the single source of truth (the active tool /
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id // mode), never a separate local selection — so keyboard shortcuts and panel
// clicks always agree on which tile is lit.
// The roof Features sub-grid arms roof-accessory tools (skylight, chimney,
// …); keep the Roof tile lit (and its panel open) while any of them is the
// active tool, the same way MEP stays lit for its sub-grid tools.
const isRoofFeatureActive =
mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool)
const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)
const isTypeActive = (type: BuildType) => {
if (type.mode === 'material-paint') return mode === 'material-paint'
if (type.id === 'mep') return isMepActive
if (type.id === 'roof')
return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive)
return mode === 'build' && activeTool === type.kind
}
const handleTypeClick = useCallback((type: BuildType) => { const handleTypeClick = useCallback((type: BuildType) => {
if (type.mode === 'material-paint') { if (type.mode === 'material-paint') {
@@ -213,15 +232,18 @@ export function BuildTab() {
} else if (type.kind) { } else if (type.kind) {
activateBuildTool(type.kind) activateBuildTool(type.kind)
} }
setSelectedTypeId(type.id)
}, []) }, [])
// On open, land on the first build tool — parity with the community Build // On open, land on the first build tool — parity with the community Build
// sidebar, so switching to Build immediately arms a usable tool. // sidebar, so switching to Build immediately arms a usable tool. Skip when a
// build tool is already active (e.g. the B shortcut armed one before this
// panel mounted): the active tool is the source of truth, not this default.
const didInitRef = useRef(false) const didInitRef = useRef(false)
useEffect(() => { useEffect(() => {
if (didInitRef.current) return if (didInitRef.current) return
didInitRef.current = true didInitRef.current = true
const ed = useEditor.getState()
if (ed.mode === 'build' && ed.tool) return
const firstType = BUILD_TYPES.find((t) => t.kind) const firstType = BUILD_TYPES.find((t) => t.kind)
if (firstType) handleTypeClick(firstType) if (firstType) handleTypeClick(firstType)
}, [handleTypeClick]) }, [handleTypeClick])
@@ -274,7 +296,9 @@ export function BuildTab() {
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
<MaterialPaintPanel /> <MaterialPaintPanel />
</div> </div>
) : selectedTypeId === 'roof' && roofFeatures.length > 0 ? ( ) : mode === 'build' &&
(activeTool === 'roof' || isRoofFeatureActive) &&
roofFeatures.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div> <div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
<TooltipProvider delayDuration={0} disableHoverableContent> <TooltipProvider delayDuration={0} disableHoverableContent>
@@ -319,7 +343,7 @@ export function BuildTab() {
</div> </div>
</TooltipProvider> </TooltipProvider>
</div> </div>
) : selectedTypeId === 'mep' ? ( ) : isMepActive ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div> <div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div>
<TooltipProvider delayDuration={0} disableHoverableContent> <TooltipProvider delayDuration={0} disableHoverableContent>
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode } from '../schema' import { CeilingNode, SlabNode, WallNode } from '../schema'
import { planAutoCeilingsForLevel } from './space-detection' import { planAutoCeilingsForLevel, planAutoSlabsForLevel } from './space-detection'
const square: Array<[number, number]> = [ const square: Array<[number, number]> = [
[0, 0], [0, 0],
@@ -90,3 +90,26 @@ describe('planAutoCeilingsForLevel', () => {
expect(plan.update).toHaveLength(0) expect(plan.update).toHaveLength(0)
}) })
}) })
describe('planAutoSlabsForLevel', () => {
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
// Two rooms with identical polygon signatures previously collided in a
// signature-keyed Map, so one detected room never matched an existing slab
// and churned (delete + recreate) on every pass.
const slabA = slab(0.05)
const slabB = slab(0.05)
const plan = planAutoSlabsForLevel([roomPolygon(), roomPolygon()], [slabA, slabB])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
expect(plan.update).toHaveLength(0)
})
test('deletes an extra auto-slab when only one identical room is detected', () => {
const plan = planAutoSlabsForLevel([roomPolygon()], [slab(0.05), slab(0.05)])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(1)
})
})
+22 -36
View File
@@ -595,43 +595,25 @@ function levelWallSnapshot(walls: WallNode[]) {
return walls.map(wallGeometrySignature).sort().join('||') return walls.map(wallGeometrySignature).sort().join('||')
} }
function slabGeometrySignature(slab: SlabNodeType) { // Trigger signature is wall-only on purpose: re-detection should fire on a
const polygon = slab.polygon // genuine remodel (wall geometry change), never when an auto-slab is edited or
.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`) // deleted. Hashing slabs here created a feedback loop where deleting an
.join(';') // auto-slab re-fired detection and recreated it.
const holes = (slab.holes ?? [])
.map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';'))
.join('/')
return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join(
'|',
)
}
function levelSlabSnapshot(slabs: SlabNodeType[]) {
return slabs.map(slabGeometrySignature).sort().join('||')
}
function levelStructureSnapshots(nodes: Record<string, any>) { function levelStructureSnapshots(nodes: Record<string, any>) {
const byLevel = new Map<string, { walls: WallNode[]; slabs: SlabNodeType[] }>() const byLevel = new Map<string, WallNode[]>()
const getEntry = (levelId: string) => {
const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] }
byLevel.set(levelId, entry)
return entry
}
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
if ((node as any).type === 'wall') { if ((node as any).type !== 'wall') continue
getEntry((node as any).parentId).walls.push(node as WallNode) const levelId = (node as any).parentId as string
} else if ((node as any).type === 'slab') { const walls = byLevel.get(levelId) ?? []
getEntry((node as any).parentId).slabs.push(SlabNode.parse(node)) walls.push(node as WallNode)
} byLevel.set(levelId, walls)
} }
const snapshots = new Map<string, string>() const snapshots = new Map<string, string>()
for (const [levelId, entry] of byLevel.entries()) { for (const [levelId, walls] of byLevel.entries()) {
snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`) snapshots.set(levelId, levelWallSnapshot(walls))
} }
return snapshots return snapshots
@@ -692,13 +674,15 @@ export function planAutoSlabsForLevel(
const matchedDetectedIdx = new Set<number>() const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, [number, number][]>() const updatesById = new Map<string, [number, number][]>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>() const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) { for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry) const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
} }
detected.forEach((room, index) => { detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig) const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return if (!existing) return
matchedDetectedIdx.add(index) matchedDetectedIdx.add(index)
@@ -875,13 +859,15 @@ export function planAutoCeilingsForLevel(
const matchedDetectedIdx = new Set<number>() const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>() const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>() const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) { for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry) const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
} }
detected.forEach((room, index) => { detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig) const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return if (!existing) return
matchedDetectedIdx.add(index) matchedDetectedIdx.add(index)
+31 -13
View File
@@ -15,9 +15,8 @@ import type { CloneNodesIntoOptions, Subtree } from './subtree'
// door cutouts read parent wall — use `ctx` to resolve those references // door cutouts read parent wall — use `ctx` to resolve those references
// without importing `useScene`. Builders stay pure and unit-testable. // without importing `useScene`. Builders stay pure and unit-testable.
// //
// Future extension: `levelData?: { miters?: ... }` for level-scoped batch // `levelData` carries level-scoped batch data (wall mitering across an
// data (wall mitering across an entire level). Decided alongside the wall // entire level) from registry dispatchers into pure builders.
// migration off its dedicated system (Phase 3+).
export type GeometryContext = { export type GeometryContext = {
/** Look up any node by ID. Returns undefined if the node doesn't exist. */ /** Look up any node by ID. Returns undefined if the node doesn't exist. */
@@ -30,18 +29,16 @@ export type GeometryContext = {
parent: AnyNode | null parent: AnyNode | null
/** /**
* Pre-computed level-batch data, populated by the dispatcher when the * Pre-computed level-batch data, populated by the dispatcher when the
* kind declares `def.computeLevelData`. Shared across every * kind declares `def.computeLevelData` (3D) or
* `def.geometry(node, ctx)` call in the same level batch within a * `def.computeFloorplanLevelData` (2D). Shared across every builder call
* single frame, so kinds whose geometry depends on cross-sibling * in the same level batch within a single frame/render pass, so kinds
* data (wall mitering, gradient sky uniforms across a zone, etc.) * whose geometry depends on cross-sibling data (wall mitering, gradient
* don't pay an O(N²) recomputation cost. * sky uniforms across a zone, etc.) don't pay an O(N²) recomputation cost.
* *
* Typed as `unknown` at the framework boundary — kinds cast to their * Typed as `unknown` at the framework boundary — kinds cast to their
* own `LevelData` shape inside `def.geometry` (the same kind owns * own `LevelData` shape inside `def.geometry` / `def.floorplan` (the
* both the `computeLevelData` return shape and the `geometry` * same kind owns both the compute hook's return shape and the builder
* consumer, so the cast is internal). Only populated for `def. * consumer, so the cast is internal).
* geometry` calls today; not used by `def.floorplan` (which already
* has cheap access to siblings through `ctx.siblings`).
*/ */
levelData?: unknown levelData?: unknown
/** /**
@@ -820,6 +817,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* runs once even when many walls are dirty in the same frame. * runs once even when many walls are dirty in the same frame.
*/ */
computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown
/**
* Floor-plan level-batch precompute hook. The floor-plan layer calls this
* once per level per render pass, de-duplicated by kind, before the
* per-node `def.floorplan` calls. The result lands in `ctx.levelData` for
* every node of this kind in the level.
*
* Used to hoist cross-sibling floor-plan work that would otherwise be
* O(N²) when rebuilding every node in a kind — e.g. wall mitering. `nodes`
* is the live-merged scene snapshot; `siblings` is every node of this kind
* in the level, also live-merged.
*/
computeFloorplanLevelData?: (args: {
siblings: ReadonlyArray<z.infer<S>>
nodes: Record<string, AnyNode>
}) => unknown
/** /**
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits * Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js * plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
@@ -877,6 +889,12 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* unset and rely on the generic overlay path. * unset and rely on the generic overlay path.
*/ */
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>> floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
/**
* Geometry reads sibling/parent/child nodes (e.g. wall miters, opening
* dimensions); the floor-plan layer must rebuild it whenever a
* sibling-affecting node is being dragged live.
*/
floorplanDependsOnSiblings?: boolean
/** /**
* Optional hook letting a kind project the `useLiveNodeOverrides` map * Optional hook letting a kind project the `useLiveNodeOverrides` map
* into a fresh `nodes` snapshot before its `def.floorplan` builder * into a fresh `nodes` snapshot before its `def.floorplan` builder
@@ -5,6 +5,7 @@ import type { AnyNodeDefinition, Capabilities, SceneApi } from '../registry/type
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { import {
canAttach, canAttach,
canHostOnTop,
clampYToHostTop, clampYToHostTop,
getSurface, getSurface,
getTopSurfaceHeight, getTopSurfaceHeight,
@@ -14,6 +15,16 @@ import {
const id = (s: string) => s as AnyNodeId const id = (s: string) => s as AnyNodeId
function makeItem(idStr: string, attachTo?: 'wall' | 'wall-side' | 'ceiling'): AnyNode {
return {
id: id(idStr),
type: 'item',
parentId: null,
visible: true,
asset: attachTo ? { attachTo } : {},
} as unknown as AnyNode
}
function makeDef( function makeDef(
kind: string, kind: string,
capabilities: Capabilities = {}, capabilities: Capabilities = {},
@@ -233,4 +244,30 @@ describe('pickHost', () => {
}) })
expect(picked?.id).toBe(id('s2')) expect(picked?.id).toBe(id('s2'))
}) })
test('excludes ceiling-mounted hosts (ceiling fan cannot be a top surface)', () => {
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
const candidates = [makeItem('fan', 'ceiling'), makeItem('table')]
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
expect(picked?.id).toBe(id('table'))
})
test('keeps wall-mounted hosts (wall shelf still hosts)', () => {
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
const candidates = [makeItem('shelf', 'wall')]
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
expect(picked?.id).toBe(id('shelf'))
})
})
describe('canHostOnTop', () => {
test('rejects ceiling-attachTo hosts', () => {
expect(canHostOnTop(makeItem('fan', 'ceiling'))).toBe(false)
})
test('accepts wall / wall-side / floor (undefined) hosts', () => {
expect(canHostOnTop(makeItem('shelf', 'wall'))).toBe(true)
expect(canHostOnTop(makeItem('sconce', 'wall-side'))).toBe(true)
expect(canHostOnTop(makeItem('table'))).toBe(true)
})
}) })
+13 -4
View File
@@ -112,6 +112,18 @@ export function getTopSurfaceHeight(host: AnyNode): number | null {
return typeof height === 'function' ? height(host) : height return typeof height === 'function' ? height(host) : height
} }
/**
* Whether `host` can receive a surface-resting (top-stacked) child. A
* ceiling-mounted item hangs from the ceiling, so its visible "top" is not a
* usable resting surface — nothing should stack on a ceiling fan. The check
* reads the instance-level `asset.attachTo` (not the host KIND, which is shared
* across all items) so a single gate covers every interaction path.
*/
export function canHostOnTop(host: AnyNode): boolean {
const attachTo = (host as { asset?: { attachTo?: string } }).asset?.attachTo
return attachTo !== 'ceiling'
}
/** /**
* Pure host-discovery helper. Given a list of candidate hosts (already * Pure host-discovery helper. Given a list of candidate hosts (already
* narrowed by spatial query) and a point, returns the first whose * narrowed by spatial query) and a point, returns the first whose
@@ -129,10 +141,7 @@ export function pickHost(args: {
const def = nodeRegistry.get(host.type) const def = nodeRegistry.get(host.type)
const hostable = def?.capabilities.hostable const hostable = def?.capabilities.hostable
if (!hostable) continue if (!hostable) continue
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) { if (!canHostOnTop(host)) continue
// capability declares specific parents; verify the placed kind's own def
// also permits this host kind.
}
if (args.hitTest && !args.hitTest(host, args.point)) continue if (args.hitTest && !args.hitTest(host, args.point)) continue
return host return host
} }
+1
View File
@@ -34,6 +34,7 @@ export {
type AttachError, type AttachError,
type AttachResult, type AttachResult,
canAttach, canAttach,
canHostOnTop,
clampYToHostTop, clampYToHostTop,
getSurface, getSurface,
getTopSurfaceHeight, getTopSurfaceHeight,
@@ -48,6 +48,7 @@ export function FloorplanRegistryActionMenu() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Gate on floorplan hover so this 2D menu never coexists with the 3D // Gate on floorplan hover so this 2D menu never coexists with the 3D
// FloatingActionMenu in split view — that menu hides while the floorplan // FloatingActionMenu in split view — that menu hides while the floorplan
// is hovered, so this one must only show then. Mirrors the legacy // is hovered, so this one must only show then. Mirrors the legacy
@@ -141,6 +142,11 @@ export function FloorplanRegistryActionMenu() {
const handleMove = () => { const handleMove = () => {
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never) setMovingNode(node as never)
// 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
// Mark the origin (after `setMovingNode`, which resets it to null) so
// `ToolManager` keeps the 3D affordance mover from also adopting the node
// and reverting it on unmount. Mirrors the orange move-dot path.
setMovingNodeOrigin('2d')
// Match the legacy 3D `floating-action-menu`: clear selection so // Match the legacy 3D `floating-action-menu`: clear selection so
// selection-gated affordances unmount during the drag. Specifically // selection-gated affordances unmount during the drag. Specifically
// the slab / ceiling boundary editor (`ToolManager` shows it when // the slab / ceiling boundary editor (`ToolManager` shows it when
@@ -12,6 +12,8 @@ import {
type GeometryContext, type GeometryContext,
isRegistryMovable, isRegistryMovable,
kindsWithFloorplanScope, kindsWithFloorplanScope,
type LiveNodeOverrides,
type LiveTransform,
nodeRegistry, nodeRegistry,
pauseSceneHistory, pauseSceneHistory,
resolveBuildingForLevel, resolveBuildingForLevel,
@@ -122,6 +124,46 @@ type RotationOverlayState = {
sweep: number sweep: number
} }
type FloorplanEntry = {
id: AnyNodeId
node: AnyNode
base: FloorplanGeometry | null
overlay: FloorplanGeometry | null
selected: boolean
highlighted: boolean
}
type NodeDeps = {
node: AnyNode
live: LiveTransform | undefined
selected: boolean
highlighted: boolean
hovered: boolean
moving: boolean
palette: FloorplanPalette | undefined
siblingEpoch: number
committedNodes: Record<string, AnyNode> | null
interactiveElevators: unknown
}
type CacheEntry = {
deps: NodeDeps
base: FloorplanGeometry | null
overlay: FloorplanGeometry | null
node: AnyNode
}
type FloorplanContextOverrides = {
children: AnyNode[]
siblings: AnyNode[]
parent: AnyNode | null
}
type FloorplanLevelDataHook = (args: {
siblings: ReadonlyArray<AnyNode>
nodes: Record<string, AnyNode>
}) => unknown
function snapshotNode(node: AnyNode): NodeSnapshot { function snapshotNode(node: AnyNode): NodeSnapshot {
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are // Shallow-clone every non-id, non-type field. Arrays / vec tuples are
// deep-cloned to detach from the live store reference. // deep-cloned to detach from the live store reference.
@@ -137,6 +179,16 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
return snapshots.map((s) => ({ id: s.id, data: s.data })) return snapshots.map((s) => ({ id: s.id, data: s.data }))
} }
// Stable empty sentinels. While the floor plan is hidden (3D-only view) the
// live-* selectors return these instead of the real maps, so the per-pointer
// drag publishes (usePlacementCoordinator → useLiveTransforms / the rotate
// gizmo → useLiveNodeOverrides) no longer re-render this layer and its hundreds
// of geometry children. The same reference each call keeps zustand from
// detecting a change; committed scene edits still flow through `useScene`, so
// the plan is current the instant the view is shown again.
const EMPTY_LIVE_TRANSFORMS: Map<string, LiveTransform> = new Map()
const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const selectedLevelId = useViewer((s) => s.selection.levelId) const selectedLevelId = useViewer((s) => s.selection.levelId)
const selectedBuildingId = useViewer((s) => s.selection.buildingId) const selectedBuildingId = useViewer((s) => s.selection.buildingId)
@@ -191,6 +243,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const renderCtx = useFloorplanRender() const renderCtx = useFloorplanRender()
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Door / window placement (both build and move) needs the SVG's // Door / window placement (both build and move) needs the SVG's
// background click handler to run — it finds the closest wall via // background click handler to run — it finds the closest wall via
// `findClosestWallPoint` and emits `wall:click` for the door / window // `findClosestWallPoint` and emits `wall:click` for the door / window
@@ -215,17 +268,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
structureLayer !== 'zones' && structureLayer !== 'zones' &&
!movingNode && !movingNode &&
!movingFenceEndpoint !movingFenceEndpoint
// While the floor plan is not on screen (pure 3D view) it must not react to
// the per-pointer drag publishes below — re-rendering this layer + its
// hundreds of geometry children every move is what tanks 3D-drag framerate
// even though nothing 2D is visible. Gating the live-* subscriptions freezes
// them to a stable empty map while hidden; committed edits still arrive via
// `useScene`, so the plan is current the moment the view is shown.
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
// Subscribe to the live-transforms map ref so the layer re-renders // Subscribe to the live-transforms map ref so the layer re-renders
// whenever a 3D mover publishes a per-frame position (see // whenever a 3D mover publishes a per-frame position (see
// `usePlacementCoordinator`). Without this the 2D floor plan only // `usePlacementCoordinator`). Without this the 2D floor plan only
// updates after 3D commit — the 3D drag would look frozen in 2D. // updates after 3D commit — the 3D drag would look frozen in 2D.
const liveTransforms = useLiveTransforms((s) => s.transforms) const liveTransforms = useLiveTransforms((s) =>
floorplanVisible ? s.transforms : EMPTY_LIVE_TRANSFORMS,
)
// Same reactivity hook for elevator runtime state — `useInteractive` // Same reactivity hook for elevator runtime state — `useInteractive`
// tracks the current / fallback level + cab travel, `useLiveNode // tracks the current / fallback level + cab travel, `useLiveNode
// Overrides` carries live-edit overrides from the inspector. Builders // Overrides` carries live-edit overrides from the inspector. Builders
// read both via `getState()` inside `def.floorplan`; subscribing here // read both via `getState()` inside `def.floorplan`; subscribing here
// is what forces the layer to re-render when they change. // is what forces the layer to re-render when they change.
const liveOverrides = useLiveNodeOverrides((s) => s.overrides) const liveOverrides = useLiveNodeOverrides((s) =>
floorplanVisible ? s.overrides : EMPTY_LIVE_OVERRIDES,
)
const interactiveElevators = useInteractive((s) => s.elevators) const interactiveElevators = useInteractive((s) => s.elevators)
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
@@ -239,6 +303,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null) const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null) const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null) const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
const siblingEpochInputsRef = useRef<unknown[]>([])
const siblingEpochRef = useRef(0)
const applyEntrySelection = useCallback( const applyEntrySelection = useCallback(
(id: AnyNodeId, shouldToggle: boolean) => { (id: AnyNodeId, shouldToggle: boolean) => {
@@ -467,117 +534,223 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// tree the builder returns. Builders don't need to know about the // tree the builder returns. Builders don't need to know about the
// partition. // partition.
const entries = useMemo(() => { const entries = useMemo(() => {
// Some builders read elevator runtime state imperatively; this keeps the memo subscribed. const previousCache = geometryCacheRef.current
void interactiveElevators const nextCache = new Map<string, CacheEntry>()
if (!levelId) {
geometryCacheRef.current = nextCache
return []
}
if (!levelId) return [] // The sibling epoch bumps whenever a sibling-affecting node's LIVE state
const out: { // changes (a wall/door/window/gutter being dragged or live-edited). Only
id: AnyNodeId // flagged kinds feed it, so dragging or rotating a plain item — which also
node: AnyNode // publishes to liveTransforms / liveOverrides — leaves it stable and the
base: FloorplanGeometry | null // hundreds of wall/door geometries stay cached. Committed structural edits
overlay: FloorplanGeometry | null // are covered separately by keying flagged kinds on the `nodes` ref.
selected: boolean const siblingEpochInputs: unknown[] = []
highlighted: boolean for (const [id, live] of liveTransforms) {
}[] = [] const node = nodes[id as AnyNodeId]
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
siblingEpochInputs.push(live)
}
}
for (const [id, override] of liveOverrides) {
const node = nodes[id as AnyNodeId]
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
siblingEpochInputs.push(override)
}
}
if (!depsValueEqual(siblingEpochInputsRef.current, siblingEpochInputs)) {
siblingEpochRef.current += 1
siblingEpochInputsRef.current = siblingEpochInputs
}
const siblingEpoch = siblingEpochRef.current
const out: FloorplanEntry[] = []
const levelDataByType = new Map<string, unknown>()
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
const collectLevelDataKind = (id: AnyNodeId) => {
const node = nodes[id]
if (!node) return
const def = nodeRegistry.get(node.type)
if (def?.computeFloorplanLevelData) {
const ids = levelNodeIdsByType.get(node.type)
if (ids) ids.push(id)
else levelNodeIdsByType.set(node.type, [id])
}
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
if (Array.isArray(childIds)) {
for (const cid of childIds) collectLevelDataKind(cid)
}
}
collectLevelDataKind(levelId as AnyNodeId)
for (const [type, ids] of levelNodeIdsByType) {
const def = nodeRegistry.get(type)
if (!def?.computeFloorplanLevelData) continue
const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
const sampleId = ids[0]
if (!sampleId) continue
const contextNodes = def.floorplanSiblingOverrides
? def.floorplanSiblingOverrides({ nodeId: sampleId, nodes, liveOverrides })
: nodes
const siblings: AnyNode[] = []
for (const id of ids) {
const sibling = contextNodes[id]
if (sibling?.type === type) siblings.push(sibling)
}
levelDataByType.set(type, computeLevelData({ siblings, nodes: contextNodes }))
}
const buildEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
const def = nodeRegistry.get(node.type)
const builder = def?.floorplan
if (!builder) return
const selected = selectedIdSet.has(id)
const highlighted = highlightedIdSet.has(id)
const hovered = hoveredId === id
const moving = movingNode?.id === id
const live = liveTransforms.get(id)
const dependsOnSiblingInputs = !!(
def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides
)
const deps: NodeDeps = {
node,
live,
selected,
highlighted,
hovered,
moving,
palette: renderCtx?.palette,
siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
// COMMITTED state via `ctx`, so a committed edit to a sibling/child that
// doesn't change this node's own ref must still invalidate it. The
// `nodes` ref is stable during a live drag (only commits replace it), so
// this preserves the live-drag cache win while matching the old
// rebuild-on-every-commit correctness. Self-contained kinds key on their
// own `node` ref only.
committedNodes: dependsOnSiblingInputs ? nodes : null,
// Elevator builders read runtime state imperatively, so every kind's
// cache key includes the rare-changing ref conservatively.
interactiveElevators,
}
const cached = previousCache.get(id)
if (cached && nodeDepsEqual(cached.deps, deps)) {
nextCache.set(id, cached)
if (cached.base || cached.overlay) {
out.push({
id,
node: cached.node,
base: cached.base,
overlay: cached.overlay,
selected,
highlighted,
})
}
return
}
const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
if (!live) return sourceNode
const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
if (sourceNode.type === 'door' || sourceNode.type === 'window') {
// Door / window movers publish WALL-LOCAL live transforms
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
// wiki/architecture/tools.md. The mover only writes
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
// updates the 3D mesh imperatively but never the scene node —
// without applying the live transform here the 2D symbol stays
// frozen while the cursor slides. Merge the wall-local position +
// rotation onto the node but KEEP `parentId` (the wall) so
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
// real swing-arc / pane symbol at the live spot.
const r = (sourceNode as { rotation?: unknown }).rotation
return {
...sourceNode,
position: live.position,
rotation: Array.isArray(r)
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
: r,
} as AnyNode
}
if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
return applyPositionLiveTransform(sourceNode, live)
}
if (
sourceNode.type === 'slab' ||
sourceNode.type === 'ceiling' ||
sourceNode.type === 'zone'
) {
const dx = live.position[0]
const dz = live.position[2]
if (dx === 0 && dz === 0) return sourceNode
const surface = sourceNode as {
polygon: Array<[number, number]>
holes?: Array<Array<[number, number]>>
}
return {
...sourceNode,
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
holes: (surface.holes ?? []).map((h) =>
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
),
} as AnyNode
}
return sourceNode
}
const contextNodes = def.floorplanSiblingOverrides
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
: nodes
const sourceNode = contextNodes !== nodes ? (contextNodes[id] ?? node) : node
const effectiveNode = applyLiveTransform(sourceNode)
const viewState = {
selected,
highlighted,
hovered,
moving,
palette: renderCtx?.palette,
}
const ctx: GeometryContext = ctxOverrides
? {
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
contextNodes[rid] as N | undefined,
children: ctxOverrides.children,
siblings: ctxOverrides.siblings,
parent: ctxOverrides.parent,
levelData: levelDataByType.get(node.type),
viewState: renderCtx?.palette
? {
selected,
highlighted,
hovered,
moving,
palette: renderCtx.palette,
}
: undefined,
}
: buildContext(effectiveNode, contextNodes, viewState, levelDataByType.get(node.type))
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
effectiveNode,
ctx,
)
const { base, overlay } = geometry
? splitFloorplanOverlay(geometry)
: { base: null, overlay: null }
const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
nextCache.set(id, entry)
if (base || overlay) {
out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
}
}
const visit = (id: AnyNodeId) => { const visit = (id: AnyNodeId) => {
const node = nodes[id] const node = nodes[id]
if (!node) return if (!node) return
if ((node as { visible?: boolean }).visible === false) return if ((node as { visible?: boolean }).visible === false) return
const def = nodeRegistry.get(node.type) buildEntry(id, node)
const builder = def?.floorplan
if (builder) {
const selected = selectedIdSet.has(id)
const highlighted = highlightedIdSet.has(id)
const hovered = hoveredId === id
const moving = movingNode?.id === id
// Live-transform override — when a mover is publishing per-frame
// position/rotation, render that here instead of the committed
// scene state. Without this the 2D floor plan would only update
// after commit, making the drag look frozen.
//
// The live-transform contract varies per kind (see
// wiki/architecture/tools.md "useLiveTransforms contract is
// per-kind, not generic"); position-carrying floor-placed kinds
// publish canonical X/Z, while slab / ceiling publish a polygon
// translation delta.
const live = liveTransforms.get(id)
let effectiveNode: AnyNode = node
if (live) {
const floorPlaced = def?.capabilities?.floorPlaced
const hasPosition = Array.isArray((node as { position?: unknown }).position)
if (node.type === 'door' || node.type === 'window') {
// Door / window movers publish WALL-LOCAL live transforms
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
// wiki/architecture/tools.md. The mover only writes
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
// updates the 3D mesh imperatively but never the scene node —
// without applying the live transform here the 2D symbol stays
// frozen while the cursor slides. Merge the wall-local position +
// rotation onto the node but KEEP `parentId` (the wall) so
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
// real swing-arc / pane symbol at the live spot.
const r = (node as { rotation?: unknown }).rotation
effectiveNode = {
...node,
position: live.position,
rotation: Array.isArray(r)
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
: r,
} as AnyNode
} else if (floorPlaced && hasPosition) {
effectiveNode = applyPositionLiveTransform(node, live)
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
const dx = live.position[0]
const dz = live.position[2]
if (dx !== 0 || dz !== 0) {
const surface = node as {
polygon: Array<[number, number]>
holes?: Array<Array<[number, number]>>
}
effectiveNode = {
...node,
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
holes: (surface.holes ?? []).map((h) =>
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
),
} as AnyNode
}
}
}
// Live-edit overrides: kinds whose `def.floorplan` builder
// reads cross-sibling data (wall miters, …) declare a
// `def.floorplanSiblingOverrides` hook that projects the
// override map into a merged `nodes` snapshot. The merged
// copy feeds `buildContext` so `ctx.siblings` reflects the
// live cursor positions, and replaces `effectiveNode` so the
// kind's own override lands too (covers the case where the
// node being rendered is itself the dragged one). Kinds
// without the hook hand the raw `nodes` through — most
// previews are self-contained.
const contextNodes = def?.floorplanSiblingOverrides
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
: nodes
if (contextNodes !== nodes) {
const merged = contextNodes[id]
if (merged) effectiveNode = merged
}
const ctx = buildContext(effectiveNode, contextNodes, {
selected,
highlighted,
hovered,
moving,
palette: renderCtx?.palette,
})
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
effectiveNode,
ctx,
)
if (geometry) {
const { base, overlay } = splitFloorplanOverlay(geometry)
out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
}
}
const childIds = (node as unknown as { children?: AnyNodeId[] }).children const childIds = (node as unknown as { children?: AnyNodeId[] }).children
if (Array.isArray(childIds)) { if (Array.isArray(childIds)) {
for (const cid of childIds) visit(cid) for (const cid of childIds) visit(cid)
@@ -607,50 +780,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const parentId = (node as { parentId?: AnyNodeId | null }).parentId const parentId = (node as { parentId?: AnyNodeId | null }).parentId
if (parentId !== activeBuildingId) continue if (parentId !== activeBuildingId) continue
const cid = id as AnyNodeId const cid = id as AnyNodeId
const def = nodeRegistry.get(node.type) buildEntry(cid, node, {
const builder = def?.floorplan
if (!builder) continue
const selected = selectedIdSet.has(cid)
const highlighted = highlightedIdSet.has(cid)
const hovered = hoveredId === cid
const moving = movingNode?.id === cid
const live = liveTransforms.get(cid)
const hasPosition = Array.isArray((node as { position?: unknown }).position)
let effectiveNode: AnyNode =
live && hasPosition ? applyPositionLiveTransform(node, live) : node
const contextNodes = def?.floorplanSiblingOverrides
? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides })
: nodes
if (contextNodes !== nodes) {
const merged = contextNodes[cid]
if (merged) {
effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged
}
}
const ctx: GeometryContext = {
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
contextNodes[rid] as N | undefined,
children: [], children: [],
siblings: [], siblings: [],
parent: activeLevelNode, parent: activeLevelNode,
viewState: renderCtx?.palette })
? {
selected,
highlighted,
hovered,
moving,
palette: renderCtx.palette,
}
: undefined,
}
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
effectiveNode,
ctx,
)
if (geometry) {
const { base, overlay } = splitFloorplanOverlay(geometry)
out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted })
}
} }
} }
@@ -662,6 +796,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// DFS visit order (stable sort) so siblings keep their relative // DFS visit order (stable sort) so siblings keep their relative
// priority. // priority.
out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type)) out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
geometryCacheRef.current = nextCache
return out return out
}, [ }, [
levelId, levelId,
@@ -991,6 +1126,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
suppressBoxSelectForPointer(event) suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never) setMovingNode(node as never)
// Claim 2D ownership of this move at the source. `setMovingNode`
// resets the origin to null, so this must follow it. It gates the
// 3D affordance mover (`ToolManager`) off entirely: without it the
// 3D `MoveItemTool` would also mount, `adopt()` the same node, and
// restore its adopt-time (original) position from its unmount
// `destroy()` — snapping a committed 2D move back to its start.
setMovingNodeOrigin('2d')
}} }}
palette={palette} palette={palette}
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0} sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
@@ -1947,6 +2089,7 @@ function buildContext(
moving: boolean moving: boolean
palette: FloorplanPalette | undefined palette: FloorplanPalette | undefined
}, },
levelData?: unknown,
): GeometryContext { ): GeometryContext {
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
@@ -1979,6 +2122,7 @@ function buildContext(
children, children,
siblings, siblings,
parent, parent,
levelData,
viewState: viewState.palette viewState: viewState.palette
? { ? {
selected: viewState.selected, selected: viewState.selected,
@@ -2071,6 +2215,37 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
return { base: g, overlay: null } return { base: g, overlay: null }
} }
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
const keys: Array<keyof NodeDeps> = [
'node',
'live',
'selected',
'highlighted',
'hovered',
'moving',
'palette',
'siblingEpoch',
'committedNodes',
'interactiveElevators',
]
for (const key of keys) {
if (!depsValueEqual(a[key], b[key])) return false
}
return true
}
function depsValueEqual(a: unknown, b: unknown): boolean {
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b)) return false
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (!Object.is(a[i], b[i])) return false
}
return true
}
return Object.is(a, b)
}
/** /**
* Z-order bucket for floor-plan rendering. Lower rank = painted first = * Z-order bucket for floor-plan rendering. Lower rank = painted first =
* sits under everything with a higher rank. SVG renders in document * sits under everything with a higher rank. SVG renders in document
@@ -35,10 +35,12 @@ import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useCallback, useMemo, useRef } from 'react' import { useCallback, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication' import { duplicateStairSubtree } from '../../lib/stair-duplication'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import useInteractionScope from '../../store/use-interaction-scope'
import { formatMeasurement, MeasurementPill } from './measurement-pill' import { formatMeasurement, MeasurementPill } from './measurement-pill'
import { NodeActionMenu } from './node-action-menu' import { NodeActionMenu } from './node-action-menu'
@@ -137,6 +139,11 @@ function getAttributeVersion(
: 0 : 0
} }
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
// dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
const _anchorBox = new THREE.Box3()
const _anchorCenter = new THREE.Vector3()
function getObjectGeometryKey(object: THREE.Object3D): string { function getObjectGeometryKey(object: THREE.Object3D): string {
const parts: string[] = [] const parts: string[] = []
object.traverse((child) => { object.traverse((child) => {
@@ -218,6 +225,10 @@ export function FloatingActionMenu() {
const activeHandleDrag = useEditor((s) => s.activeHandleDrag) const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
// R/T rotation axis for kinds with full 3D orientation (duct fittings). // R/T rotation axis for kinds with full 3D orientation (duct fittings).
const rotationAxis = useEditor((s) => s.rotationAxis) const rotationAxis = useEditor((s) => s.rotationAxis)
// The floating action menu is an action-conflicting control: hard-hidden
// during any active interaction so it never competes with the live action.
const scope = useInteractionScope((s) => s.scope)
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
const groupRef = useRef<THREE.Group>(null) const groupRef = useRef<THREE.Group>(null)
const menuScaleRef = useRef<HTMLDivElement>(null) const menuScaleRef = useRef<HTMLDivElement>(null)
@@ -329,23 +340,44 @@ export function FloatingActionMenu() {
// mid-resize). A spinning child changes the head's matrix, not the // mid-resize). A spinning child changes the head's matrix, not the
// registered group's, so it never triggers a recompute → the menu // registered group's, so it never triggers a recompute → the menu
// holds still. // holds still.
// Cheapest guards first: a selection swap, the object's own world
// transform changing (true every frame during a drag), a live override,
// or an active handle drag all force a recompute on their own — so skip
// the geometry traversal (`getObjectGeometryKey` walks the whole subtree
// reading attribute versions) until none of them fired and a
// geometry-only change is the only thing left that could move the anchor.
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
const dragActive = activeHandleDrag?.nodeId === selectedId const dragActive = activeHandleDrag?.nodeId === selectedId
const effectiveNode = getEffectiveNode(node)
const geometryKey = getObjectGeometryKey(obj)
const selectionChanged = const selectionChanged =
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld) const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
const geometryChanged = lastAnchorKeyRef.current.geometryKey !== geometryKey
if (selectionChanged || matrixChanged || geometryChanged || overrideActive || dragActive) { let geometryKey = lastAnchorKeyRef.current.geometryKey
let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive
// Only when nothing cheaper fired do we pay for the subtree traversal —
// a geometry-only change is the lone remaining trigger. When a cheaper
// guard already forced a recompute the stored key is reused; the matrix
// (or override/drag) keeps recomputing the anchor every frame, so a
// geometry edit mid-drag is absorbed, and the next idle frame refreshes
// the key against the live geometry.
if (!needsRecompute) {
geometryKey = getObjectGeometryKey(obj)
if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true
}
if (needsRecompute) {
const effectiveNode = getEffectiveNode(node)
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) { if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
const box = new THREE.Box3().setFromObject(obj) _anchorBox.setFromObject(obj)
if (!box.isEmpty()) { if (!_anchorBox.isEmpty()) {
const center = box.getCenter(new THREE.Vector3()) _anchorBox.getCenter(_anchorCenter)
// Position above the object. Per-type offsets clear each kind's // Position above the object. Per-type offsets clear each kind's
// in-world chrome (height-resize arrows, measurement labels). // in-world chrome (height-resize arrows, measurement labels).
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(effectiveNode), center.z) anchorRef.current.set(
_anchorCenter.x,
_anchorBox.max.y + getMenuYOffset(effectiveNode),
_anchorCenter.z,
)
hasAnchorRef.current = true hasAnchorRef.current = true
} }
} else { } else {
@@ -624,7 +656,8 @@ export function FloatingActionMenu() {
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint || movingWallEndpoint ||
movingFenceEndpoint || movingFenceEndpoint ||
curvingFence curvingFence ||
menuStepBack
) )
return null return null
@@ -75,9 +75,11 @@ import {
buildFloorplanItemEntry, buildFloorplanItemEntry,
buildFloorplanStairEntry as buildSharedFloorplanStairEntry, buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
collectLevelDescendants, collectLevelDescendants,
floorplanLocalToWorldPoint,
getFloorplanWall as getSharedFloorplanWall, getFloorplanWall as getSharedFloorplanWall,
rotatePlanVector as rotateSharedPlanVector, rotatePlanVector as rotateSharedPlanVector,
type FloorplanNodeTransform as SharedFloorplanNodeTransform, type FloorplanNodeTransform as SharedFloorplanNodeTransform,
worldToFloorplanLocalPoint,
} from '../../lib/floorplan' } from '../../lib/floorplan'
import { guideEmitter } from '../../lib/guide-events' import { guideEmitter } from '../../lib/guide-events'
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements' import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
@@ -87,7 +89,11 @@ import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor' import useEditor, {
isAngleSnapActive,
isMagneticSnapActive,
selectSiteFloorplanContext,
} from '../../store/use-editor'
import usePlacementPreview from '../../store/use-placement-preview' import usePlacementPreview from '../../store/use-placement-preview'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
@@ -1793,39 +1799,6 @@ function cameraAzimuthFromFloorplanRotation(rotationDeg: number) {
return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG) return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG)
} }
function floorplanLocalToWorldPoint(
point: SvgPoint | WallPlanPoint,
buildingPosition: readonly [number, number, number],
buildingRotationY: number,
): { x: number; z: number } {
const localX = Array.isArray(point) ? point[0] : point.x
const localY = Array.isArray(point) ? point[1] : point.y
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
return {
x: buildingPosition[0] + localX * cos + localY * sin,
z: buildingPosition[2] - localX * sin + localY * cos,
}
}
function worldToFloorplanLocalPoint(
worldX: number,
worldZ: number,
buildingPosition: readonly [number, number, number],
buildingRotationY: number,
): SvgPoint {
const dx = worldX - buildingPosition[0]
const dz = worldZ - buildingPosition[2]
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
return {
x: dx * cos - dz * sin,
y: dx * sin + dz * cos,
}
}
function projectSvgPointToSurface( function projectSvgPointToSurface(
svgPoint: SvgPoint, svgPoint: SvgPoint,
viewBox: { minX: number; minY: number; width: number; height: number }, viewBox: { minX: number; minY: number; width: number; height: number },
@@ -7678,7 +7651,7 @@ export function FloorplanPanel({
walls, walls,
ignoreWallIds: [dragState.wallId], ignoreWallIds: [dragState.wallId],
bypassSnap, bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap, magnetic: !bypassSnap && isMagneticSnapActive(),
}) })
const snappedPoint = snapResult.point const snappedPoint = snapResult.point
// Magnetic beacon at the endpoint when it locked onto existing geometry. // Magnetic beacon at the endpoint when it locked onto existing geometry.
@@ -8537,30 +8510,30 @@ export function FloorplanPanel({
} }
if (isFenceBuildActive) { if (isFenceBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
// Figma alignment — same endpoint-wins precedence as the wall branch. // Figma alignment — same endpoint-wins precedence as the wall branch.
// 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.
// unless Shift is held; Shift bypasses grid, magnetic, angle, and // Snapping is governed by the snapping mode (`'off'` is the bypass);
// alignment snap. // there is no Shift hold-to-bypass. Alt still bypasses Figma alignment.
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
const fenceSnapped = snapFenceDraftPoint({ const fenceSnapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
fences, fences,
start: fenceDraftStart ?? undefined, start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap, angleSnap: fenceAngleSnap,
bypassSnap, magnetic: isMagneticSnapActive(),
}) })
const fenceGridBase = bypassSnap ? planPoint : snapWallPointToGrid(planPoint) const fenceGridBase = snapWallPointToGrid(planPoint)
const fenceLocked = const fenceLocked =
!bypassSnap && fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
let snappedPoint = fenceSnapped let snappedPoint = fenceSnapped
if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear() if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear()
else else
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
bypass: event.altKey || bypassSnap, // Alignment is a line snap (pulls onto existing corners/edges) —
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
bypass: event.altKey || !isMagneticSnapActive(),
}) })
emitFloorplanGridEvent('move', snappedPoint, event) emitFloorplanGridEvent('move', snappedPoint, event)
@@ -8738,18 +8711,16 @@ export function FloorplanPanel({
} }
// Wall draft: grid + magnetic snap, then Figma-style alignment. // Wall draft: grid + magnetic snap, then Figma-style alignment.
// While a draft is open the segment locks to 15° rays from its // While a draft is open the segment locks to 15° rays from its start.
// start unless Shift is held. Shift bypasses grid, magnetic, angle, // Snapping is governed by the snapping mode (`'off'` is the bypass);
// and alignment snap. // there is no Shift hold-to-bypass. Alt still bypasses Figma alignment.
const bypassSnap = shiftPressed || event.shiftKey const wallAngleSnap = draftStart !== null && isAngleSnapActive()
const wallAngleSnap = draftStart !== null && !bypassSnap
const wallSnap = snapWallDraftPointDetailed({ const wallSnap = snapWallDraftPointDetailed({
point: planPoint, point: planPoint,
walls, walls,
start: draftStart ?? undefined, start: draftStart ?? undefined,
angleSnap: wallAngleSnap, angleSnap: wallAngleSnap,
bypassSnap, magnetic: isMagneticSnapActive(),
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}) })
const wallSnapped = wallSnap.point const wallSnapped = wallSnap.point
// Locked onto existing geometry (corner / midpoint / crossing / edge) → // Locked onto existing geometry (corner / midpoint / crossing / edge) →
@@ -8761,7 +8732,9 @@ export function FloorplanPanel({
} else { } else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, { snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap, applySnap: !wallAngleSnap,
bypass: event.altKey || bypassSnap, // Alignment is a line snap (pulls onto existing corners/edges) —
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
bypass: event.altKey || !isMagneticSnapActive(),
}) })
} }
useWallSnapIndicator useWallSnapIndicator
@@ -8780,8 +8753,9 @@ export function FloorplanPanel({
setDraftEnd((previousEnd) => { setDraftEnd((previousEnd) => {
if ( if (
!bypassSnap && !previousEnd ||
(!previousEnd || previousEnd[0] !== snappedPoint[0] || previousEnd[1] !== snappedPoint[1]) previousEnd[0] !== snappedPoint[0] ||
previousEnd[1] !== snappedPoint[1]
) { ) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -9044,7 +9018,7 @@ export function FloorplanPanel({
angleSnap?: boolean angleSnap?: boolean
bypassSnap?: boolean bypassSnap?: boolean
step?: number step?: number
}) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }), }) => snapWallDraftPoint({ ...args, magnetic: isMagneticSnapActive() }),
[], [],
) )
const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({ const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
@@ -269,15 +269,27 @@ export function createArrowHitAreaGeometry() {
return geometry return geometry
} }
// The move cross is a plus, not a disk. A disk-shaped hit area fills the four
// corner gaps between the arms, so a neighbouring node sitting next to the
// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the
// invisible grip and can't be picked. Wrap the visible arms instead: two flat
// arm boxes (length/width + margin) merged into a plus, leaving the corners
// empty so co-located neighbours stay selectable while the grip stays grabbable.
function createMoveCrossHitAreaGeometry() { function createMoveCrossHitAreaGeometry() {
const geometry = new CylinderGeometry( const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth)
HIT_AREA_THICKNESS, const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength)
32, const merged = mergeGeometries([armX, armZ], false)
) if (!merged) {
geometry.computeBoundingSphere() armZ.dispose()
return geometry armX.computeBoundingSphere()
return armX
}
armX.dispose()
armZ.dispose()
merged.computeBoundingSphere()
return merged
} }
export function createRotateArrowHitAreaGeometry() { export function createRotateArrowHitAreaGeometry() {
@@ -43,6 +43,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import { 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 { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
@@ -177,7 +178,6 @@ export function NodeArrowHandles() {
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const placementDragMode = useEditor((state) => state.placementDragMode)
// Endpoint / curve drags reshape the selected wall or fence; hide its // Endpoint / curve drags reshape the selected wall or fence; hide its
// resize arrows for the duration so they don't clutter (or get blocked // resize arrows for the duration so they don't clutter (or get blocked
// by) the drag's own cursor + dimension overlays. Mirrors the same guard // by) the drag's own cursor + dimension overlays. Mirrors the same guard
@@ -203,9 +203,6 @@ export function NodeArrowHandles() {
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode), () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
[rawNode, liveOverride], [rawNode, liveOverride],
) )
const isOwnPressDragMove =
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
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
@@ -218,7 +215,11 @@ export function NodeArrowHandles() {
Boolean(node && descriptors?.length) && Boolean(node && descriptors?.length) &&
!isFloorplanHovered && !isFloorplanHovered &&
mode !== 'delete' && mode !== 'delete' &&
(!movingNode || isOwnPressDragMove) && // Any whole-node move (placement or press-drag) hides the rig: the item is
// following the cursor, so its rotate/resize handles would only clutter and
// draw stray selection rays. The active handle-drag scope (resize/rotate)
// sets `activeHandleDrag`, not `movingNode`, so those are unaffected.
!movingNode &&
!movingWallEndpoint && !movingWallEndpoint &&
!movingFenceEndpoint && !movingFenceEndpoint &&
!curvingWall && !curvingWall &&
@@ -398,22 +399,30 @@ function NodeArrowHandlesForNode({
// resize that re-centres the mesh) must NOT fire for the non-active arrows // resize that re-centres the mesh) must NOT fire for the non-active arrows
// here, or they'd lag behind the moving item. // here, or they'd lag behind the moving item.
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate' const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
// While a rotate gizmo is mid-drag, drop the opposite-side move cross: you
// can't move and rotate at once, so it only clutters the rotation.
const activeDescriptor = activeIndex !== null ? descriptors[activeIndex] : undefined
const activeIsRotate =
!!activeDescriptor && 'shape' in activeDescriptor && activeDescriptor.shape === 'rotate'
const arrows = descriptors.map((descriptor, index) => ( const arrows = descriptors.map((descriptor, index) => {
<ArrowHandle if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
activeIndex={activeIndex} return (
descriptor={descriptor} <ArrowHandle
dragControls={dragControls} activeIndex={activeIndex}
handleIndex={index} descriptor={descriptor}
// Descriptors come from a per-node-kind static list, so index is a dragControls={dragControls}
// stable identity within this node's selection cycle. handleIndex={index}
key={index} // Descriptors come from a per-node-kind static list, so index is a
liveNode={node} // stable identity within this node's selection cycle.
preDragNode={preDragNode} key={index}
rideObject={arrowFrame} liveNode={node}
suppressFreeze={activeIsTranslate} preDragNode={preDragNode}
/> rideObject={arrowFrame}
)) suppressFreeze={activeIsTranslate}
/>
)
})
return createPortal( return createPortal(
<group ref={outerRef}> <group ref={outerRef}>
@@ -1135,8 +1144,21 @@ function ArcArrow({
} }
const initialAngle = angleOf(hitWorld) const initialAngle = angleOf(hitWorld)
// Advertise the rotate interaction so the contextual HUD can surface the
// Shift = free-rotation toggle (the angle-step bypass below). Resize
// handles route a measurement label here; rotate gets a sentinel label so
// the HUD shows the rotate hint, not a dimension pill.
if (isRotateShape) {
useEditor
.getState()
.setActiveHandleDrag({ nodeId: node.id, label: ROTATE_HANDLE_DRAG_LABEL })
}
return { return {
onEnd: () => setRotationDelta(null), onEnd: () => {
setRotationDelta(null)
if (isRotateShape) useEditor.getState().setActiveHandleDrag(null)
},
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
const hit = new Vector3() const hit = new Vector3()
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
@@ -6,10 +6,11 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides' import useAlignmentGuides from '../../store/use-alignment-guides'
import { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import usePlacementPreview from '../../store/use-placement-preview' import usePlacementPreview from '../../store/use-placement-preview'
import useSegmentDraftChain from '../../store/use-segment-draft-chain' import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting' import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting'
type UseFloorplanBackgroundPlacementArgs = { type UseFloorplanBackgroundPlacementArgs = {
activePolygonDraftPoints: WallPlanPoint[] activePolygonDraftPoints: WallPlanPoint[]
@@ -212,8 +213,8 @@ export function useFloorplanBackgroundPlacement({
// start unless Shift is held; Shift bypasses grid, magnetic, // start unless Shift is held; Shift bypasses grid, magnetic,
// angle, and alignment snap. `gridSnap` keeps the regular snap // angle, and alignment snap. `gridSnap` keeps the regular snap
// on the world XZ grid even when the building is rotated. // on the world XZ grid even when the building is rotated.
const fenceStep = WALL_GRID_STEP const fenceStep = getSegmentGridStep()
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap && isAngleSnapActive()
const fenceSnapped = snapFenceDraftPoint({ const fenceSnapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
@@ -221,6 +222,7 @@ export function useFloorplanBackgroundPlacement({
start: fenceDraftStart ?? undefined, start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap, angleSnap: fenceAngleSnap,
bypassSnap, bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
gridSnap: (p) => worldGridSnap(p, fenceStep), gridSnap: (p) => worldGridSnap(p, fenceStep),
}) })
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep) const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep)
@@ -230,7 +232,9 @@ export function useFloorplanBackgroundPlacement({
const snappedPoint = const snappedPoint =
fenceLocked || fenceAngleSnap fenceLocked || fenceAngleSnap
? fenceSnapped ? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap }) : alignFloorplanDraftPoint(fenceSnapped, {
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(),
})
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
@@ -321,8 +325,8 @@ export function useFloorplanBackgroundPlacement({
// start unless Shift is held; Shift bypasses grid, magnetic, // start unless Shift is held; Shift bypasses grid, magnetic,
// angle, and alignment snap. `gridSnap` keeps the regular snap // angle, and alignment snap. `gridSnap` keeps the regular snap
// on the world XZ grid even when the building is rotated. // on the world XZ grid even when the building is rotated.
const wallStep = WALL_GRID_STEP const wallStep = getSegmentGridStep()
const wallAngleSnap = draftStart !== null && !bypassSnap const wallAngleSnap = draftStart !== null && !bypassSnap && isAngleSnapActive()
const wallSnapped = snapWallDraftPoint({ const wallSnapped = snapWallDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
@@ -340,7 +344,10 @@ export function useFloorplanBackgroundPlacement({
} else { } else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, { snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap, applySnap: !wallAngleSnap,
bypass: event.altKey || bypassSnap, // Figma alignment pulls the endpoint onto existing wall corners /
// edges, so it is a line snap — suppress it whenever magnetic snap
// is off (`'off'` / `'angles'`), matching the wall-geometry snap.
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(),
}) })
} }
@@ -6,8 +6,10 @@ import { Check, Pencil } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
// ─── Per-zone label editor ──────────────────────────────────────────────────── // ─── Per-zone label editor ────────────────────────────────────────────────────
@@ -19,6 +21,10 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
const selectedZoneId = useViewer((s) => s.selection.zoneId) const selectedZoneId = useViewer((s) => s.selection.zoneId)
const hoveredId = useViewer((s) => s.hoveredId) const hoveredId = useViewer((s) => s.hoveredId)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
// During an active interaction the zone label is a context badge that steps
// back: faded + non-interactive so it can't be hovered/clicked mid-action.
const scope = useInteractionScope((s) => s.scope)
const labelStepBack = resolveOverlayPolicy(scope).contextBadges === 'faded'
const isSelected = selectedZoneId === zoneId const isSelected = selectedZoneId === zoneId
const isDeleteHovered = mode === 'delete' && hoveredId === zoneId const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
@@ -149,7 +155,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
fontSize: 14, fontSize: 14,
fontFamily: 'sans-serif', fontFamily: 'sans-serif',
userSelect: 'none', userSelect: 'none',
pointerEvents: 'auto', pointerEvents: labelStepBack ? 'none' : 'auto',
opacity: labelStepBack ? 0.4 : undefined,
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 4,
@@ -3,7 +3,9 @@ import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { type Group, MathUtils, type Mesh } from 'three' import { type Group, MathUtils, type Mesh } from 'three'
import type { MeshBasicNodeMaterial } from 'three/webgpu' import type { MeshBasicNodeMaterial } from 'three/webgpu'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
// Disable raycasting on zone geometry so clicks pass through to items underneath. // Disable raycasting on zone geometry so clicks pass through to items underneath.
// Zone selection in the editor is handled exclusively via the HTML label overlay. // Zone selection in the editor is handled exclusively via the HTML label overlay.
@@ -20,6 +22,11 @@ export const ZoneSystem = () => {
// geometry or the HTML zone tags in the framed shot. // geometry or the HTML zone tags in the framed shot.
const isCaptureMode = useEditor.getState().isCaptureMode const isCaptureMode = useEditor.getState().isCaptureMode
// During any active interaction zone labels step back entirely — they are
// not a primary editing concern and would distract / invite misclicks.
const zoneLabelsHidden =
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
const zoneGeometryVisible = structureLayer === 'zones' const zoneGeometryVisible = structureLayer === 'zones'
const zones = sceneRegistry.byType.zone || new Set() const zones = sceneRegistry.byType.zone || new Set()
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
@@ -84,7 +91,8 @@ export const ZoneSystem = () => {
// Labels: visible on the current level (regardless of mode), but never // Labels: visible on the current level (regardless of mode), but never
// during snapshot capture. // during snapshot capture.
const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel const showLabel =
!isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
const labelOpacity = showLabel ? '1' : '0' const labelOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${zoneId}-label`) const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== labelOpacity) { if (labelEl && labelEl.style.opacity !== labelOpacity) {
@@ -1,9 +1,16 @@
import { type AssetInput, isObject } from '@pascal-app/core' import { type AssetInput, isObject } from '@pascal-app/core'
import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
// Sentinel returned when the active snapping mode disables grid snapping.
// The snap helpers below treat any `step <= 0` as "no grid snap" and pass the
// raw value through. When grid snapping is enabled (the default `'grid'` mode)
// this returns the user's `gridSnapStep` exactly as before — so the default
// path is byte-identical to the pre-mode behaviour.
function getGridSnapStep(): number { function getGridSnapStep(): number {
return useEditor.getState().gridSnapStep const state = useEditor.getState()
return resolveSnapFlags(state.snappingMode).grid ? state.gridSnapStep : 0
} }
function positiveModulo(value: number, divisor: number): number { function positiveModulo(value: number, divisor: number): number {
@@ -14,6 +21,7 @@ function positiveModulo(value: number, divisor: number): number {
* Snaps a position to the active grid step, aligning item edges to grid lines. * Snaps a position to the active grid step, aligning item edges to grid lines.
*/ */
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number { export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
if (step <= 0) return position
const halfDim = dimension / 2 const halfDim = dimension / 2
const offset = positiveModulo(halfDim, step) const offset = positiveModulo(halfDim, step)
return Math.round((position - offset) / step) * step + offset return Math.round((position - offset) / step) * step + offset
@@ -23,6 +31,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
* Snap a value to the active grid step (used for wall-local positions). * Snap a value to the active grid step (used for wall-local positions).
*/ */
export function snapToHalf(value: number, step = getGridSnapStep()): number { export function snapToHalf(value: number, step = getGridSnapStep()): number {
if (step <= 0) return value
return Math.round(value / step) * step return Math.round(value / step) * step
} }
@@ -30,6 +39,7 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
* Round a value up to the next multiple of `step`, with a minimum of `step`. * Round a value up to the next multiple of `step`, with a minimum of `step`.
*/ */
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number { export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
if (step <= 0) return value
return Math.max(step, Math.ceil(value / step) * step) return Math.max(step, Math.ceil(value / step) * step)
} }
@@ -16,6 +16,7 @@ import type {
WallNode, WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
canHostOnTop,
clampRectToRoofWallFace, clampRectToRoofWallFace,
getRoofSegmentWallFace, getRoofSegmentWallFace,
getScaledDimensions, getScaledDimensions,
@@ -64,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
} }
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) { function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
if (!canHostOnTop(surfaceItem)) return null
if (isLowProfileItemSurface(surfaceItem)) return null if (isLowProfileItemSurface(surfaceItem)) return null
if (!isUpwardItemSurfaceHit(event)) return null if (!isUpwardItemSurfaceHit(event)) return null
@@ -113,7 +115,7 @@ export const floorStrategy = {
// is rotated; then project the world point back into building-local // is rotated; then project the world point back into building-local
// for storage. Without this, a rotated building drags placement off // for storage. Without this, a rotated building drags placement off
// the world grid. // the world grid.
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const [x, z] = bypassSnap const [x, z] = bypassSnap
? [event.localPosition[0], event.localPosition[2]] ? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding( : snapWorldXZForActiveBuilding(
@@ -202,7 +204,7 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2]) const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
@@ -266,7 +268,7 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2]) const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
@@ -393,14 +395,14 @@ type RoofWallTarget = {
* `wall-side` items mount on the outer surface, `wall` items center in * `wall-side` items mount on the outer surface, `wall` items center in
* the wall thickness. * the wall thickness.
* *
* `shiftFree` mirrors the wall flow's Shift override (stubbed * `freePlace` mirrors the wall flow's Alt override (stubbed
* validators): the profile clamp is skipped, so the rect may overhang * validators): the profile clamp is skipped, so the rect may overhang
* the face edges — placement follows the snapped cursor as-is. * the face edges — placement follows the snapped cursor as-is.
*/ */
function resolveRoofWallTarget( function resolveRoofWallTarget(
ctx: PlacementContext, ctx: PlacementContext,
event: RoofEvent, event: RoofEvent,
shiftFree = false, freePlace = false,
): RoofWallTarget | null { ): RoofWallTarget | null {
const attachTo = ctx.asset.attachTo const attachTo = ctx.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
@@ -414,10 +416,10 @@ function resolveRoofWallTarget(
const dims = getGridAlignedDimensions(rawDims, attachTo) const dims = getGridAlignedDimensions(rawDims, attachTo)
const [width, height] = dims const [width, height] = dims
const u = shiftFree ? hit.u : snapToHalf(hit.u) const u = freePlace ? hit.u : snapToHalf(hit.u)
const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2 const centerV = (freePlace ? hit.v : snapToHalf(hit.v)) + height / 2
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height) const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
if (!fitted && !shiftFree) return null if (!fitted && !freePlace) return null
const finalU = fitted?.u ?? u const finalU = fitted?.u ?? u
const finalV = fitted?.v ?? centerV const finalV = fitted?.v ?? centerV
@@ -483,8 +485,8 @@ export const roofWallStrategy = {
* face. Returns null when the item doesn't wall-attach or the pointer * face. Returns null when the item doesn't wall-attach or the pointer
* isn't over a placeable face. * isn't over a placeable face.
*/ */
enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null { enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null {
const target = resolveRoofWallTarget(ctx, event, shiftFree) const target = resolveRoofWallTarget(ctx, event, freePlace)
if (!target) return null if (!target) return null
return { return {
@@ -511,11 +513,11 @@ export const roofWallStrategy = {
* segment transitions inside one roof never re-fire roof:enter) or to * segment transitions inside one roof never re-fire roof:enter) or to
* no placeable face. * no placeable face.
*/ */
move(ctx: PlacementContext, event: RoofEvent, shiftFree = false): PlacementResult | null { move(ctx: PlacementContext, event: RoofEvent, freePlace = false): PlacementResult | null {
if (ctx.state.surface !== 'roof-wall') return null if (ctx.state.surface !== 'roof-wall') return null
if (!ctx.draftItem) return null if (!ctx.draftItem) return null
const target = resolveRoofWallTarget(ctx, event, shiftFree) const target = resolveRoofWallTarget(ctx, event, freePlace)
if (!target) return null if (!target) return null
if (target.segment.id !== ctx.state.roofSegmentId) return null if (target.segment.id !== ctx.state.roofSegmentId) return null
@@ -538,12 +540,12 @@ export const roofWallStrategy = {
/** /**
* Handle roof:click — commit placement on the segment wall face. * Handle roof:click — commit placement on the segment wall face.
*/ */
click(ctx: PlacementContext, _event: RoofEvent, shiftFree = false): CommitResult | null { click(ctx: PlacementContext, _event: RoofEvent, freePlace = false): CommitResult | null {
if (ctx.state.surface !== 'roof-wall') return null if (ctx.state.surface !== 'roof-wall') return null
if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
// Shift mirrors the wall flow's stubbed validators: skip profile-fit // Alt mirrors the wall flow's stubbed validators: skip profile-fit
// and overlap checks entirely. // and overlap checks entirely.
if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
return { return {
nodeUpdate: { nodeUpdate: {
@@ -615,7 +617,7 @@ export const ceilingStrategy = {
// Ceiling items are stored in ceiling-local coordinates, so snapping must // Ceiling items are stored in ceiling-local coordinates, so snapping must
// use the ceiling hit's local position rather than world position. // use the ceiling hit's local position rather than world position.
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const x = bypassSnap const x = bypassSnap
? event.localPosition[0] ? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) : snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
@@ -654,7 +656,7 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem.rotation?.[1] ?? 0 const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9 const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const x = bypassSnap const x = bypassSnap
? event.localPosition[0] ? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) : snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
@@ -771,7 +773,7 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null if (surfaceHeight === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight const y = surfaceHeight
@@ -823,7 +825,7 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null if (surfaceHeight === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight const y = surfaceHeight
@@ -924,7 +926,7 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null if (rowY === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
@@ -969,7 +971,7 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null if (rowY === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
@@ -44,7 +44,7 @@ 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'
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap' import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
import useEditor from '../../../store/use-editor' import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
createLineGeometry, createLineGeometry,
@@ -221,7 +221,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
shelfId: null, shelfId: null,
}, },
) )
const shiftFreeRef = useRef(false) const altFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null) const previewBoundsSignatureRef = useRef<string | null>(null)
// Goes true the first time a 3D pointer event drives this coordinator. // Goes true the first time a 3D pointer event drives this coordinator.
// The per-frame mesh-position lerp below is only useful for that path; // The per-frame mesh-position lerp below is only useful for that path;
@@ -441,7 +441,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}) })
const getActiveValidators = () => const getActiveValidators = () =>
shiftFreeRef.current altFreeRef.current
? { ? {
canPlaceOnFloor: () => ({ valid: true }), canPlaceOnFloor: () => ({ valid: true }),
canPlaceOnWall: () => ({ valid: true }), canPlaceOnWall: () => ({ valid: true }),
@@ -450,7 +450,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
: validators : validators
const revalidate = (): boolean => { const revalidate = (): boolean => {
const placeable = shiftFreeRef.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
edgeMaterial.color.setHex(color) edgeMaterial.color.setHex(color)
basePlaneMaterial.color.setHex(color) basePlaneMaterial.color.setHex(color)
@@ -610,8 +610,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Floor grab-offset: the item tracks the grabbed point instead of snapping // Floor grab-offset: the item tracks the grabbed point instead of snapping
// its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid // its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid
// (`event.position`) on its default path and only reads `event.localPosition` // (`event.position`) on its default path and only reads `event.localPosition`
// under Shift, so both frames must carry the offset; the world point is // under Alt (free place), so both frames must carry the offset; the world
// derived from the corrected local one so the two stay consistent. // point is derived from the corrected local one so the two stay consistent.
const applyFloorGrabOffset = (event: GridEvent): GridEvent => { const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
if (relativeFloorStart === null) return event if (relativeFloorStart === null) return event
const rawX = event.localPosition[0] const rawX = event.localPosition[0]
@@ -773,12 +773,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// item's edge, snap and publish a guide. The guide connects to the // item's edge, snap and publish a guide. The guide connects to the
// nearest real corner of the candidate (resolver tie-break), so the dot // nearest real corner of the candidate (resolver tie-break), so the dot
// always sits on an actual point. The delta is applied to BOTH the grid // always sits on an actual point. The delta is applied to BOTH the grid
// and cursor positions below. Alt bypasses alignment; Shift bypasses all snap. // and cursor positions below. Alt (free place) bypasses all snap; the
// active snapping mode governs whether alignment runs at all ('off' /
// 'angles' disable magnetic alignment, matching the wall/fence flow).
const draft = draftNode.current const draft = draftNode.current
let alignX = 0 let alignX = 0
let alignZ = 0 let alignZ = 0
const bypassSnap = floorEvent.nativeEvent?.shiftKey === true const freePlace = floorEvent.nativeEvent?.altKey === true
const bypassAlign = floorEvent.nativeEvent?.altKey === true || bypassSnap const bypassAlign = freePlace || !isMagneticSnapActive()
if (!bypassAlign && draft) { if (!bypassAlign && draft) {
alignmentCandidates ??= collectAlignmentAnchors( alignmentCandidates ??= collectAlignmentAnchors(
useScene.getState().nodes, useScene.getState().nodes,
@@ -812,7 +814,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Play snap sound when grid position changes // Play snap sound when grid position changes
if ( if (
!bypassSnap && !freePlace &&
previousGridPos && previousGridPos &&
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2]) (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
) { ) {
@@ -997,7 +999,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
// Play snap sound when grid position changes // Play snap sound when grid position changes
if (event.nativeEvent?.shiftKey !== true && posChanged) { if (event.nativeEvent?.altKey !== true && posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1121,7 +1123,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// re-enters whenever the strategy reports a segment change. // re-enters whenever the strategy reports a segment change.
const enterRoofWall = (event: RoofEvent): boolean => { const enterRoofWall = (event: RoofEvent): boolean => {
const result = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current) const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
if (!result) return false if (!result) return false
event.stopPropagation() event.stopPropagation()
@@ -1152,7 +1154,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return return
} }
const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current) const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
if (!result) { if (!result) {
// Different segment under the pointer (or no placeable face) — // Different segment under the pointer (or no placeable face) —
// try a fresh enter; a null resolve leaves the draft where it is. // try a fresh enter; a null resolve leaves the draft where it is.
@@ -1167,7 +1169,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
if (!shiftFreeRef.current && posChanged) { if (!altFreeRef.current && posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1210,7 +1212,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onRoofWallClick = (event: RoofEvent) => { const onRoofWallClick = (event: RoofEvent) => {
const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current) const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
if (!result) return if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -1220,7 +1222,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draftNode.commit(result.nodeUpdate) draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { if (configRef.current.onCommitted()) {
const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current) const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
@@ -1261,7 +1263,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.position[1], event.position[1],
event.position[2], event.position[2],
) )
const bypassSnap = event.nativeEvent?.shiftKey === true const bypassSnap = event.nativeEvent?.altKey === true
const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2 const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2
const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2 const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2
const floorPos: [number, number, number] = [wx, 0, wz] const floorPos: [number, number, number] = [wx, 0, wz]
@@ -1598,7 +1600,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
if (event.nativeEvent?.shiftKey !== true && posChanged) { if (event.nativeEvent?.altKey !== true && posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1793,8 +1795,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// items (use-keyboard.ts) so the ghost/duplicate rotates the same way. // items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
const ROTATION_STEP = Math.PI / 4 const ROTATION_STEP = Math.PI / 4
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') { if (event.key === 'Alt') {
shiftFreeRef.current = true altFreeRef.current = true
revalidate() revalidate()
return return
} }
@@ -1908,8 +1910,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onKeyUp = (event: KeyboardEvent) => { const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') { if (event.key === 'Alt') {
shiftFreeRef.current = false altFreeRef.current = false
revalidate() revalidate()
} }
} }
@@ -1997,6 +1999,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('shelf:move', onShelfMove) emitter.on('shelf:move', onShelfMove)
emitter.on('shelf:click', onShelfClick) emitter.on('shelf:click', onShelfClick)
emitter.on('shelf:leave', onShelfLeave) emitter.on('shelf:leave', onShelfLeave)
// A floor placement commits at the tracked floor cursor (`gridPosition`),
// which keeps following the floor even when the click ray lands on a wall
// (grid:move uses a separate ground-plane raycast). Without this, a commit
// click whose ray hits a wall fires only `wall:click` — whose handler
// declines for a floor item — and the click is silently eaten (the user
// has to click again until the ray happens to clear the wall). Route every
// surface click to the floor commit too; `floorStrategy.click` guards on
// `surface === 'floor'` (and a non-attach draft), so it no-ops while the
// draft is actually resting on that surface.
const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
if (placementState.current.surface !== 'floor') return
onGridClick(event as unknown as GridEvent)
}
emitter.on('wall:click', commitFloorOnSurfaceClick as never)
emitter.on('item:click', commitFloorOnSurfaceClick as never)
emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
emitter.on('roof:click', commitFloorOnSurfaceClick as never)
emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
if (dragMode) window.addEventListener('pointerup', onReleaseCommit) if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
return () => { return () => {
@@ -2032,6 +2053,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('shelf:move', onShelfMove) emitter.off('shelf:move', onShelfMove)
emitter.off('shelf:click', onShelfClick) emitter.off('shelf:click', onShelfClick)
emitter.off('shelf:leave', onShelfLeave) emitter.off('shelf:leave', onShelfLeave)
emitter.off('wall:click', commitFloorOnSurfaceClick as never)
emitter.off('item:click', commitFloorOnSurfaceClick as never)
emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
emitter.off('roof:click', commitFloorOnSurfaceClick as never)
emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
@@ -2114,7 +2140,7 @@ 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])
useFrame((_, delta) => { useFrame(() => {
if (!asset) { if (!asset) {
reconcileDraftRaycast(null) reconcileDraftRaycast(null)
return return
@@ -2145,12 +2171,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
mesh.visible = true mesh.visible = true
if (placementState.current.surface === 'floor') { if (placementState.current.surface === 'floor') {
const distance = mesh.position.distanceToSquared(gridPosition.current) // Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
if (distance > 1) { // active move visibly trail the cursor and — combined with React
mesh.position.copy(gridPosition.current) // re-renders momentarily pulling the mesh back toward its committed
} else { // position — read as a laggy snap-back on every move. Copying each frame
mesh.position.lerp(gridPosition.current, delta * 20) // locks placement/move to the cursor and overrides any stray reset
} // within a single frame, so it feels precise instead of dragging.
mesh.position.copy(gridPosition.current)
// Adjust Y for slab elevation (floor items on top of slabs) // Adjust Y for slab elevation (floor items on top of slabs)
if (!asset.attachTo) { if (!asset.attachTo) {
@@ -30,7 +30,8 @@ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
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'
@@ -41,7 +42,9 @@ import { PlacementBox } from '../shared/placement-box'
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */ * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
const snapToGridStep = (value: number) => { const snapToGridStep = (value: number) => {
const step = useEditor.getState().gridSnapStep const state = useEditor.getState()
if (!resolveSnapFlags(state.snappingMode).grid) return value
const step = state.gridSnapStep
return Math.round(value / step) * step return Math.round(value / step) * step
} }
@@ -244,10 +247,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// a register collar drops onto a duct run end. Reads `def.ports` through // a register collar drops onto a duct run end. Reads `def.ports` through
// the core registry, so it stays layer-clean (no @pascal-app/nodes import). // the core registry, so it stays layer-clean (no @pascal-app/nodes import).
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
// Mirrors of `valid` / Shift for the event handlers inside the effect, which // Mirrors of `valid` / Alt for the event handlers inside the effect, which
// can't read React state without stale closures. // can't read React state without stale closures.
const validRef = useRef(true) const validRef = useRef(true)
const shiftRef = useRef(false) const altRef = useRef(false)
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
@@ -259,7 +262,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
dragAnchorRef.current = null dragAnchorRef.current = null
hasMovedRef.current = false hasMovedRef.current = false
rotationRef.current = originalRotationY rotationRef.current = originalRotationY
shiftRef.current = false altRef.current = false
validRef.current = true validRef.current = true
// Re-sync the box transform to the (possibly new) node. `node` changes // Re-sync the box transform to the (possibly new) node. `node` changes
// without this component remounting whenever a positioned preset re-arms a // without this component remounting whenever a positioned preset re-arms a
@@ -335,12 +338,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
// Re-run the floor-collision check at the live cursor + rotation and push // Re-run the floor-collision check at the live cursor + rotation and push
// the result to the box colour. Shift forces a valid (green) override so // the result to the box colour. Alt (free place) forces a valid (green)
// the user can drop on top of an existing item on purpose. Only shelves // override so the user can drop on top of an existing item on purpose. Only
// show the box, so this no-ops for every other movable kind. // shelves show the box, so this no-ops for every other movable kind.
const recomputeValidity = () => { const recomputeValidity = () => {
if (!boxDimensions) return if (!boxDimensions) return
if (shiftRef.current) { if (altRef.current) {
validRef.current = true validRef.current = true
setValid(true) setValid(true)
return return
@@ -417,7 +420,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
original: [originalPosition[0], originalPosition[2]], original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchorRef.current, anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep, snap: event.nativeEvent?.altKey === true ? (value) => value : snapToGridStep,
}) })
dragAnchorRef.current = resolved.anchor dragAnchorRef.current = resolved.anchor
let [x, z] = resolved.point let [x, z] = resolved.point
@@ -426,8 +429,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// moving item's edge lines up (on X or Z) with another item's edge, // moving item's edge lines up (on X or Z) with another item's edge,
// snap and publish a guide. The guide connects to the nearest real // snap and publish a guide. The guide connects to the nearest real
// corner of the candidate (resolver tie-break), so the dot always sits // corner of the candidate (resolver tie-break), so the dot always sits
// on an actual point. Alt bypasses alignment; Shift bypasses all snap. // on an actual point. Alt (free place) bypasses all snap; the active
const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true // snapping mode governs whether magnetic alignment runs at all.
const freePlace = event.nativeEvent?.altKey === true
const bypass = freePlace || !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationRef.current), moving: movingFootprintAnchors(node, x, z, rotationRef.current),
@@ -488,7 +493,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
previewConnectivity(position, rotationRef.current) previewConnectivity(position, rotationRef.current)
const prev = previousSnapRef.current const prev = previousSnapRef.current
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) { if (!freePlace && (!prev || prev[0] !== x || prev[1] !== z)) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
previousSnapRef.current = [x, z] previousSnapRef.current = [x, z]
} }
@@ -524,9 +529,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// deliberate drop. Prevents preset re-arm from double-placing. // deliberate drop. Prevents preset re-arm from double-placing.
if (!hasMovedRef.current) return if (!hasMovedRef.current) return
// Refuse a drop on an invalid (red) footprint, matching the GLB item // Refuse a drop on an invalid (red) footprint, matching the GLB item
// tool — unless Shift is held to force placement. Other kinds carry no // tool — unless Alt (free place) is held to force placement. Other kinds
// validity box (`validRef` stays true), so they're never blocked. // carry no validity box (`validRef` stays true), so they're never blocked.
if (!validRef.current && !shiftRef.current) return if (!validRef.current && !altRef.current) return
const position: [number, number, number] = [...lastCursorRef.current] const position: [number, number, number] = [...lastCursorRef.current]
const rotation = toCommitRotation(rotationRef.current) const rotation = toCommitRotation(rotationRef.current)
@@ -624,10 +629,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// item placement keys (and the "Rotate" hints the move HUD shows). Applied // item placement keys (and the "Rotate" hints the move HUD shows). Applied
// imperatively + mirrored to the live transform; committed on drop. // imperatively + mirrored to the live transform; committed on drop.
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
// Hold Shift to force placement on an invalid (red) footprint, matching // Hold Alt (free place) to force placement on an invalid (red) footprint,
// the GLB item tool. Recolour the box to green while held. // matching the GLB item tool. Recolour the box to green while held.
if (e.key === 'Shift') { if (e.key === 'Alt') {
shiftRef.current = true altRef.current = true
recomputeValidity() recomputeValidity()
return return
} }
@@ -659,8 +664,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
recomputeValidity() recomputeValidity()
} }
const onKeyUp = (e: KeyboardEvent) => { const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') { if (e.key === 'Alt') {
shiftRef.current = false altRef.current = false
recomputeValidity() recomputeValidity()
} }
} }
@@ -4,6 +4,7 @@ import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { Box3, type Camera, type Object3D, Vector3 } from 'three' import { Box3, type Camera, type Object3D, Vector3 } from 'three'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import { import {
clearBoxSelectHandled, clearBoxSelectHandled,
isBoxSelectPointerSuppressed, isBoxSelectPointerSuppressed,
@@ -191,6 +192,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
const currentClientXRef = useRef(0) const currentClientXRef = useRef(0)
const currentClientYRef = useRef(0) const currentClientYRef = useRef(0)
const spaceDownRef = useRef(false) const spaceDownRef = useRef(false)
// rAF throttle for the expensive marquee preview pass. pointermove can fire
// several times per animation frame; the per-node AABB projection in
// `collectNodeIdsInScreenRect` only needs to run once per frame. We stash the
// latest clamped rect and process it inside the rAF callback.
const previewRafRef = useRef<number | null>(null)
const pendingPreviewRectRef = useRef<ScreenRect | null>(null)
const syncPreviewSelectedIds = useCallback( const syncPreviewSelectedIds = useCallback(
(nextIds: string[]) => { (nextIds: string[]) => {
@@ -206,6 +213,11 @@ const ScreenRectangleSelectTool: React.FC = () => {
pointerDownRef.current = false pointerDownRef.current = false
isDraggingRef.current = false isDraggingRef.current = false
pointerIdRef.current = null pointerIdRef.current = null
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current)
previewRafRef.current = null
}
pendingPreviewRectRef.current = null
hideScreenRectangleSelectionElement(elementRef.current) hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
@@ -213,6 +225,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
useViewer.getState().setInputDragging(false) useViewer.getState().setInputDragging(false)
ownsInputDraggingRef.current = false ownsInputDraggingRef.current = false
} }
useInteractionScope.getState().endIf((s) => s.kind === 'box-select')
}, [syncPreviewSelectedIds]) }, [syncPreviewSelectedIds])
useEffect(() => { useEffect(() => {
@@ -263,6 +276,14 @@ const ScreenRectangleSelectTool: React.FC = () => {
useEffect(() => { useEffect(() => {
const canvas = gl.domElement const canvas = gl.domElement
const flushPreview = () => {
previewRafRef.current = null
const rect = pendingPreviewRectRef.current
if (!rect) return
pendingPreviewRectRef.current = null
syncPreviewSelectedIds(collectNodeIdsInScreenRect(rect, camera, canvas))
}
const updateDrag = (event: PointerEvent) => { const updateDrag = (event: PointerEvent) => {
if (!pointerDownRef.current) return if (!pointerDownRef.current) return
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
@@ -291,6 +312,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
isDraggingRef.current = true isDraggingRef.current = true
ownsInputDraggingRef.current = true ownsInputDraggingRef.current = true
useViewer.getState().setInputDragging(true) useViewer.getState().setInputDragging(true)
useInteractionScope.getState().begin({ kind: 'box-select' })
markBoxSelectHandled() markBoxSelectHandled()
try { try {
canvas.setPointerCapture(event.pointerId) canvas.setPointerCapture(event.pointerId)
@@ -311,13 +333,22 @@ const ScreenRectangleSelectTool: React.FC = () => {
screenRectFromDomRect(canvas.getBoundingClientRect()), screenRectFromDomRect(canvas.getBoundingClientRect()),
) )
if (!clampedRect) { if (!clampedRect) {
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current)
previewRafRef.current = null
}
pendingPreviewRectRef.current = null
hideScreenRectangleSelectionElement(elementRef.current) hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
return return
} }
updateScreenRectangleSelectionElement(elementRef.current!, clampedRect) updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas)) // Coalesce the per-node AABB projection to one run per animation frame.
pendingPreviewRectRef.current = clampedRect
if (previewRafRef.current === null) {
previewRafRef.current = requestAnimationFrame(flushPreview)
}
} }
const finishDrag = (event: PointerEvent) => { const finishDrag = (event: PointerEvent) => {
@@ -56,6 +56,7 @@ export const ToolManager: React.FC = () => {
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall) const curvingWall = useEditor((state) => state.curvingWall)
@@ -134,6 +135,16 @@ export const ToolManager: React.FC = () => {
// Show build tools when in build mode // Show build tools when in build mode
const showBuildTool = mode === 'build' && tool !== null const showBuildTool = mode === 'build' && tool !== null
// A move initiated from the 2D floor-plan (orange move-dot) is owned end-to-
// end by `FloorplanRegistryMoveOverlay`, which marks the origin `'2d'` at
// dot-down. Mounting the 3D affordance mover alongside it would adopt the
// same node and, on its unmount, restore the adopt-time position — snapping
// the committed 2D move back to its start. Gate the 3D mover off for 2D moves
// (the scene writes the overlay makes still mirror into the 3D view). A
// 3D-initiated move leaves the origin null until its own commit, so this only
// suppresses the 3D tool for genuinely 2D-owned moves.
const showMover = movingNode != null && movingNodeOrigin !== '2d'
// Registry-first: if the active tool's kind has a NodeDefinition with a // Registry-first: if the active tool's kind has a NodeDefinition with a
// tool contribution, the registry-driven tool takes over. // tool contribution, the registry-driven tool takes over.
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
@@ -163,7 +174,7 @@ export const ToolManager: React.FC = () => {
<> <>
{/* World-space tools: site boundary and building movement operate in world coordinates */} {/* World-space tools: site boundary and building movement operate in world coordinates */}
{showSiteBoundaryEditor && <SiteBoundaryEditor />} {showSiteBoundaryEditor && <SiteBoundaryEditor />}
{movingNode?.type === 'building' && ( {showMover && movingNode?.type === 'building' && (
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} /> <MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
)} )}
@@ -259,7 +270,7 @@ export const ToolManager: React.FC = () => {
</Suspense> </Suspense>
) : null ) : null
})()} })()}
{movingNode && movingNode.type !== 'building' && ( {showMover && movingNode.type !== 'building' && (
<MoveTool <MoveTool
onNodeMoved={handlePlacedNodeSelected} onNodeMoved={handlePlacedNodeSelected}
onSpawnMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected}
@@ -13,7 +13,8 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
import { import {
distanceSquared, distanceSquared,
findWallSnapTarget, findWallSnapTarget,
@@ -51,10 +52,16 @@ type WallSplitIntersection = {
} }
export function getSegmentGridStep(): number { export function getSegmentGridStep(): number {
return useEditor.getState().gridSnapStep const state = useEditor.getState()
// A 0 step means "no grid lattice" — every grid-snap consumer guards on
// `step <= 0` and returns the raw value, so disabling grid here suppresses
// the lattice for walls, fences, and every node move/affordance that reads
// this choke point, without retuning their snap math.
return resolveSnapFlags(state.snappingMode).grid ? state.gridSnapStep : 0
} }
export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number { export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
if (step <= 0) return value
return Math.round(value / step) * step return Math.round(value / step) * step
} }
@@ -404,32 +411,38 @@ export function createWallOnCurrentLevel(
let resolvedStart = start let resolvedStart = start
let resolvedEnd = end let resolvedEnd = end
const endIntersection = findWallIntersection(resolvedEnd, workingWalls) // The corner-join / wall-split snap on commit is a magnetic (line) snap, so
const splitEnd = splitWallIfNeeded( // it must be gated by the snapping mode like the draft preview is. Without
endIntersection, // this gate `'off'` (and `'angles'`) still snapped the committed endpoint to
workingWalls, // existing wall geometry — the residual snap the draft path no longer does.
nodes, if (isMagneticSnapActive()) {
createNodes, const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
updateNodes, const splitEnd = splitWallIfNeeded(
deleteNode, endIntersection,
) workingWalls,
if (splitEnd) { nodes,
workingWalls = splitEnd.walls createNodes,
resolvedEnd = splitEnd.point updateNodes,
} deleteNode,
)
if (splitEnd) {
workingWalls = splitEnd.walls
resolvedEnd = splitEnd.point
}
const startIntersection = findWallIntersection(resolvedStart, workingWalls) const startIntersection = findWallIntersection(resolvedStart, workingWalls)
const splitStart = splitWallIfNeeded( const splitStart = splitWallIfNeeded(
startIntersection, startIntersection,
workingWalls, workingWalls,
nodes, nodes,
createNodes, createNodes,
updateNodes, updateNodes,
deleteNode, deleteNode,
) )
if (splitStart) { if (splitStart) {
workingWalls = splitStart.walls workingWalls = splitStart.walls
resolvedStart = splitStart.point resolvedStart = splitStart.point
}
} }
if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) { if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
@@ -1,35 +1,129 @@
import { Icon } from '@iconify/react'
import type { ContextualShortcutHint } from '../../../lib/contextual-help' import type { ContextualShortcutHint } from '../../../lib/contextual-help'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
import { ShortcutToken } from '../primitives/shortcut-token' import { ShortcutToken } from '../primitives/shortcut-token'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
const PILL_CLASS =
'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'
function ShortcutSequence({ keys }: { keys: string[] }) { function ShortcutSequence({ keys }: { keys: string[] }) {
return ( return (
<div className="flex flex-wrap items-center gap-0.5"> <div className="flex shrink-0 items-center gap-1">
{keys.map((key, index) => ( {keys.map((key, index) => (
<div className="flex items-center gap-0.5" key={`${key}-${index}`}> <div className="flex items-center gap-1" 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-5 px-1.5 text-[10px]" value={key} /> <ShortcutToken className="h-6 px-1.5 text-[10px]" value={key} />
</div> </div>
))} ))}
</div> </div>
) )
} }
export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) { const SNAPPING_MODE_ICONS = {
if (hints.length === 0) return null grid: 'lucide:grid-2x2',
lines: 'lucide:magnet',
angles: 'lucide:triangle',
off: 'lucide:ban',
} as const
const SNAPPING_MODE_LABELS = {
grid: 'Grid',
lines: 'Lines',
angles: 'Angles',
off: 'Off',
} as const
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
function nextGridSnapStep(step: GridSnapStep): GridSnapStep {
const index = GRID_SNAP_STEPS.indexOf(step)
return GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
}
// Interactive chip rows: the active interaction's own snapping controls. The
// surrounding stack is `pointer-events-none` (passive key hints), so these
// pills carve out `pointer-events-auto` to stay clickable.
function SnappingChips() {
const snappingMode = useEditor((s) => s.snappingMode)
const cycleSnappingMode = useEditor((s) => s.cycleSnappingMode)
const gridSnapStep = useEditor((s) => s.gridSnapStep)
const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
const gridActive = resolveSnapFlags(snappingMode).grid
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 gap-1.5 rounded-lg border border-border bg-background/95 px-3 py-2.5 shadow-lg backdrop-blur-md"> <>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
onClick={() => cycleSnappingMode()}
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]}
width={13}
/>
<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 ? (
<Tooltip>
<TooltipTrigger asChild>
<button
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))}
type="button"
>
<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}
</>
)
}
export function ContextualHelperPanel({
hints,
showSnapping = false,
}: {
hints: ContextualShortcutHint[]
showSnapping?: boolean
}) {
if (hints.length === 0 && !showSnapping) return null
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">
{showSnapping ? <SnappingChips /> : null}
{hints.map((hint) => ( {hints.map((hint) => (
<div <div
className={cn( className={cn(
'grid min-w-0 grid-cols-1 gap-1 rounded-md text-sm', PILL_CLASS,
hint.active && '-mx-1 bg-primary/10 px-1.5 py-1 text-foreground', '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} />
<span className="min-w-0 text-muted-foreground text-xs leading-snug">{hint.label}</span>
</div> </div>
))} ))}
</div> </div>
@@ -10,7 +10,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useIsMobile } from '../../../hooks/use-mobile' import { useIsMobile } from '../../../hooks/use-mobile'
import { resolveSelectModeHelpHints } from '../../../lib/contextual-help' import {
ROTATE_HANDLE_DRAG_LABEL,
resolveRotateHandleHelpHints,
resolveSelectModeHelpHints,
} from '../../../lib/contextual-help'
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation' import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper' import { BuildingHelper } from './building-helper'
@@ -62,6 +66,7 @@ export function HelperManager() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool) const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const modifiers = useActiveModifierKeys() const modifiers = useActiveModifierKeys()
@@ -87,9 +92,16 @@ export function HelperManager() {
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null if (isMobile) return null
// Rotating a node via its in-world gizmo: advertise Shift = free rotation,
// the same angle-step bypass wall drafting exposes. Takes priority over the
// idle select-mode hints since a handle drag is the active interaction.
if (activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL) {
return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
}
if (movingNode) { if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate /> if (movingNode.type === 'building') return <BuildingHelper showRotate />
return <ItemHelper shiftPressed={modifiers.shift} showEsc /> return <ItemHelper showEsc />
} }
if (mode === 'material-paint') { if (mode === 'material-paint') {
@@ -2,21 +2,18 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
interface ItemHelperProps { interface ItemHelperProps {
showEsc?: boolean showEsc?: boolean
shiftPressed?: boolean
} }
export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) { export function ItemHelper({ showEsc }: ItemHelperProps) {
return ( return (
<ContextualHelperPanel <ContextualHelperPanel
showSnapping
hints={[ hints={[
{ keys: ['Left click'], label: 'Place item' }, { keys: ['Left click'], label: 'Place item' },
{ keys: ['R'], label: 'Rotate counterclockwise' }, { keys: ['R'], label: 'Rotate counterclockwise' },
{ keys: ['T'], label: 'Rotate clockwise' }, { keys: ['T'], label: 'Rotate clockwise' },
{ { keys: ['Shift'], label: 'Cycle snapping mode' },
keys: ['Shift'], { keys: ['Alt'], label: 'Free place (no snap)' },
label: shiftPressed ? 'Guided constraints bypassed' : 'Free place',
active: shiftPressed,
},
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' }, { keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
]} ]}
/> />
@@ -20,12 +20,19 @@ export function RegisteredToolHelper({
if (hints.length === 0) return null if (hints.length === 0) return null
return ( return (
<ContextualHelperPanel <ContextualHelperPanel
hints={hints.map((hint) => ({ showSnapping
keys: [hint.key], hints={hints.map((hint) => {
label: // Shift is a per-kind bypass for item / opening / zone / duct placement
shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label, // ("Free place", "Free angle", …) — those hints flip to a bypassed
active: shiftPressed && hint.key === 'Shift', // state while held. For wall / fence, Shift now cycles the snapping
}))} // mode (no hold-to-bypass), so it must NOT show the bypass treatment.
const isBypassHint = hint.key === 'Shift' && hint.label !== 'Cycle snapping mode'
return {
keys: [hint.key],
label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label,
active: shiftPressed && isBypassHint,
}
})}
/> />
) )
} }
@@ -3,6 +3,7 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) { export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) {
return ( return (
<ContextualHelperPanel <ContextualHelperPanel
showSnapping
hints={[ hints={[
{ keys: ['Left click'], label: 'Set corner' }, { keys: ['Left click'], label: 'Set corner' },
{ {
@@ -4,13 +4,18 @@ import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { resolveOverlayPolicy } from '../lib/interaction/overlay-policy'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
import useInteractionScope from '../store/use-interaction-scope'
export const ViewerZoneSystem = () => { export const ViewerZoneSystem = () => {
useFrame(() => { useFrame(() => {
const { levelId, zoneId } = useViewer.getState().selection const { levelId, zoneId } = useViewer.getState().selection
const structureLayer = useEditor.getState().structureLayer const structureLayer = useEditor.getState().structureLayer
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
// During any active interaction zone labels step back entirely (Sims-light).
const zoneLabelsHidden =
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
sceneRegistry.byType.zone!.forEach((id) => { sceneRegistry.byType.zone!.forEach((id) => {
const obj = sceneRegistry.nodes.get(id) const obj = sceneRegistry.nodes.get(id)
@@ -35,7 +40,7 @@ export const ViewerZoneSystem = () => {
}) })
// Labels: always visible on the current level (regardless of mode or zone selection) // Labels: always visible on the current level (regardless of mode or zone selection)
const showLabel = !!levelId && isOnSelectedLevel const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
const targetOpacity = showLabel ? '1' : '0' const targetOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${id}-label`) const labelEl = document.getElementById(`${id}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) { if (labelEl && labelEl.style.opacity !== targetOpacity) {
+73 -1
View File
@@ -40,12 +40,56 @@ export const useKeyboard = ({
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window') return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
} }
// Shift cycles the snapping mode while a snapping-mode-governed draft is
// armed: wall / fence build, item placement (build + item tool), and any
// active node move (`movingNode` — covers item 3D moves plus the generic
// registry move for shelf / spawn / column / stair). For items, free place
// moved to Alt, so Shift is free to cycle here too. Elsewhere Shift keeps
// its existing meaning — multi-select in plain select mode (no movingNode),
// free-place bypass during opening / zone placement — so this predicate
// must NOT fire for those. Door / window moves still use Shift for free
// place (out of this overhaul's scope), so they're excluded.
const isSnappingCycleContext = () => {
const ed = useEditor.getState()
const moving = ed.movingNode
if (moving != null) return moving.type !== 'door' && moving.type !== 'window'
return (
ed.mode === 'build' && (ed.tool === 'wall' || ed.tool === 'fence' || ed.tool === 'item')
)
}
// 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
const handleKeyDown = (e: KeyboardEvent) => { 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
} else {
// Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
// the clean tap.
ctrlTapClean = false
}
// Don't handle shortcuts if user is typing in an input // Don't handle shortcuts if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return return
} }
if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) {
// Cycle the global snapping mode (grid → lines → angles → off).
// `'off'` is the snap bypass now, so Shift no longer holds-to-bypass.
e.preventDefault()
useEditor.getState().cycleSnappingMode()
sfxEmitter.emit('sfx:grid-snap')
return
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
_toolCancelConsumed = false _toolCancelConsumed = false
@@ -91,6 +135,9 @@ export const useKeyboard = ({
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('furnish') useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('build') useEditor.getState().setMode('build')
// Set the item tool explicitly so the active tool never inherits a
// stale tool from a prior build session.
useEditor.getState().setTool('item')
useEditor.getState().setActiveSidebarPanel('items') useEditor.getState().setActiveSidebarPanel('items')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
@@ -98,6 +145,8 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('zones') useEditor.getState().setStructureLayer('zones')
useEditor.getState().setMode('build') useEditor.getState().setMode('build')
// Set the zone tool explicitly so it never inherits a stale tool.
useEditor.getState().setTool('zone')
} }
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault() e.preventDefault()
@@ -109,6 +158,9 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements') useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('build') useEditor.getState().setMode('build')
// Set the wall tool explicitly so B never inherits a stale tool
// (e.g. fence) left over from a prior build session.
useEditor.getState().setTool('wall')
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) { } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
@@ -346,8 +398,28 @@ export const useKeyboard = ({
} }
} }
} }
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key !== 'Control' && e.key !== 'Meta') return
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.
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
if (!isSnappingCycleContext()) return
// Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
useEditor.getState().cycleGridSnapStep()
sfxEmitter.emit('sfx:grid-snap')
}
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [disabled, isVersionPreviewMode]) }, [disabled, isVersionPreviewMode])
return null return null
+1 -1
View File
@@ -327,7 +327,7 @@ export type {
ViewMode, ViewMode,
WorkspaceMode, WorkspaceMode,
} from './store/use-editor' } from './store/use-editor'
export { default as useEditor } from './store/use-editor' export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from './store/use-editor'
export { export {
default as useOpeningGuides, default as useOpeningGuides,
type OpeningGuide3D, type OpeningGuide3D,
@@ -49,7 +49,10 @@ describe('resolveSelectModeHelpHints', () => {
keys: ['Cmd/Ctrl', 'Right click'], keys: ['Cmd/Ctrl', 'Right click'],
label: 'Drag left or right to rotate selected object', label: 'Drag left or right to rotate selected object',
}) })
expect(hints).toContainEqual({ // The Shift bypass hint is gated to the in-progress direct-move gesture
// (Cmd/Ctrl held); on an idle selection it must not appear (Shift there
// means multi-select, not bypass).
expect(hints).not.toContainEqual({
keys: ['Shift'], keys: ['Shift'],
label: 'Hold to bypass snaps and angle steps', label: 'Hold to bypass snaps and angle steps',
active: false, active: false,
+29 -5
View File
@@ -4,6 +4,25 @@ export type ContextualShortcutHint = {
active?: boolean active?: boolean
} }
// `activeHandleDrag.label` value a rotate gizmo sets while dragging, so the
// contextual HUD can surface the Shift = free-rotation toggle for the duration
// (mirrors how wall drafting advertises Shift). Distinct from resize handles,
// which route their own measurement label here.
export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle'
// Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step
// (free rotation), the same toggle wall drafting exposes. `active` lights the
// pill while Shift is held.
export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualShortcutHint[] {
return [
{
keys: [SHIFT_KEY],
label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely',
active: shiftPressed,
},
]
}
export type SelectModeHelpContext = { export type SelectModeHelpContext = {
selectedCount: number selectedCount: number
hasMovableSelection: boolean hasMovableSelection: boolean
@@ -79,11 +98,16 @@ export function resolveSelectModeHelpHints({
} }
} }
hints.push({ // The Shift bypass only applies to an in-progress direct move/rotate
keys: [SHIFT_KEY], // (the Cmd/Ctrl-drag gesture), so only surface it while that modifier is
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps', // engaged — not on an idle selection, where Shift means multi-select.
active: shiftPressed, if (commandPressed && (hasMovableSelection || hasRotatableSelection)) {
}) hints.push({
keys: [SHIFT_KEY],
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
active: shiftPressed,
})
}
if (!commandPressed) { if (!commandPressed) {
hints.push({ hints.push({
@@ -11,6 +11,46 @@ export function rotatePlanVector(x: number, y: number, rotation: number): [numbe
return [x * cos + y * sin, -x * sin + y * cos] return [x * cos + y * sin, -x * sin + y * cos]
} }
// Converts a world X/Z point into the floor-plan-local (building-local)
// frame used by the SVG scene `<g>` and every stored node position. The
// inverse of `floorplanLocalToWorldPoint`. Shared so the floor-plan panel
// and the 2D move overlay resolve the same frame — feeding a world-space
// `original` into a local-space cursor solver lands the drop off by the
// building's world X/Z (worse for an off-origin building).
export function worldToFloorplanLocalPoint(
worldX: number,
worldZ: number,
buildingPosition: readonly [number, number, number],
buildingRotationY: number,
): Point2D {
const dx = worldX - buildingPosition[0]
const dz = worldZ - buildingPosition[2]
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
return {
x: dx * cos - dz * sin,
y: dx * sin + dz * cos,
}
}
// Inverse of `worldToFloorplanLocalPoint`: floor-plan-local X/Y → world X/Z.
export function floorplanLocalToWorldPoint(
point: Point2D | [number, number],
buildingPosition: readonly [number, number, number],
buildingRotationY: number,
): { x: number; z: number } {
const localX = Array.isArray(point) ? point[0] : point.x
const localY = Array.isArray(point) ? point[1] : point.y
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
return {
x: buildingPosition[0] + localX * cos + localY * sin,
z: buildingPosition[2] - localX * sin + localY * cos,
}
}
export function getRotatedRectanglePolygon( export function getRotatedRectanglePolygon(
center: Point2D, center: Point2D,
width: number, width: number,
@@ -8,6 +8,7 @@ export {
export { export {
clampPlanValue, clampPlanValue,
doesPolygonIntersectSelectionBounds, doesPolygonIntersectSelectionBounds,
floorplanLocalToWorldPoint,
getDistanceToWallSegment, getDistanceToWallSegment,
getFloorplanSelectionBounds, getFloorplanSelectionBounds,
getPlanPointDistance, getPlanPointDistance,
@@ -20,6 +21,7 @@ export {
movePlanPointTowards, movePlanPointTowards,
pointMatchesWallPlanPoint, pointMatchesWallPlanPoint,
rotatePlanVector, rotatePlanVector,
worldToFloorplanLocalPoint,
} from './geometry' } from './geometry'
export { export {
buildFloorplanItemEntry, buildFloorplanItemEntry,
@@ -0,0 +1,128 @@
import { describe, expect, test } from 'bun:test'
import {
type AttachClass,
attachClassOf,
type HotSetCandidate,
isCandidateInHotSet,
isPickableForAttach,
} from './hot-set'
const floor: HotSetCandidate = {
type: 'level',
isFloorLike: true,
exposesTop: false,
attachClass: 'surface',
}
const wall: HotSetCandidate = {
type: 'wall',
isFloorLike: false,
exposesTop: false,
attachClass: 'surface',
}
const ceiling: HotSetCandidate = {
type: 'ceiling',
isFloorLike: false,
exposesTop: false,
attachClass: 'surface',
}
const table: HotSetCandidate = {
type: 'item',
isFloorLike: false,
exposesTop: true,
attachClass: 'surface',
}
const wallShelf: HotSetCandidate = {
type: 'shelf',
isFloorLike: false,
exposesTop: true,
attachClass: 'wall',
}
const ceilingFan: HotSetCandidate = {
type: 'item',
isFloorLike: false,
exposesTop: true,
attachClass: 'ceiling',
}
describe('attachClassOf', () => {
test('wall and wall-side collapse to wall', () => {
expect(attachClassOf('wall')).toBe('wall')
expect(attachClassOf('wall-side')).toBe('wall')
})
test('ceiling maps to ceiling', () => {
expect(attachClassOf('ceiling')).toBe('ceiling')
})
test('undefined/null/unknown is surface-resting', () => {
expect(attachClassOf(undefined)).toBe('surface')
expect(attachClassOf(null)).toBe('surface')
expect(attachClassOf('')).toBe('surface')
})
})
describe('isPickableForAttach — wall-mounted (window)', () => {
test('only walls are eligible; floor/ceiling/tops are not', () => {
expect(isPickableForAttach('wall', wall)).toBe(true)
expect(isPickableForAttach('wall', floor)).toBe(false)
expect(isPickableForAttach('wall', ceiling)).toBe(false)
expect(isPickableForAttach('wall', table)).toBe(false)
expect(isPickableForAttach('wall', wallShelf)).toBe(false)
})
})
describe('isPickableForAttach — ceiling-mounted', () => {
test('only ceilings are eligible', () => {
expect(isPickableForAttach('ceiling', ceiling)).toBe(true)
expect(isPickableForAttach('ceiling', wall)).toBe(false)
expect(isPickableForAttach('ceiling', floor)).toBe(false)
})
})
describe('isPickableForAttach — surface-resting (sofa / cactus)', () => {
test('floor is always eligible', () => {
expect(isPickableForAttach('surface', floor)).toBe(true)
})
test('host tops (table, wall-shelf top) are eligible', () => {
expect(isPickableForAttach('surface', table)).toBe(true)
expect(isPickableForAttach('surface', wallShelf)).toBe(true)
})
test('a wall (no top surface) is not eligible', () => {
expect(isPickableForAttach('surface', wall)).toBe(false)
})
test('a ceiling-mounted host (ceiling fan) is never eligible — Track E', () => {
expect(isPickableForAttach('surface', ceilingFan)).toBe(false)
})
})
describe('isCandidateInHotSet — by scope', () => {
const surfaceClass: AttachClass = 'surface'
test('idle: everything is in the hot-set (selection filtering lives elsewhere)', () => {
expect(isCandidateInHotSet({ kind: 'idle' }, null, ceilingFan)).toBe(true)
})
test('placing a surface item: derives from attach class', () => {
const scope = {
kind: 'placing' as const,
nodeId: 'i1',
nodeType: 'item',
view: '3d' as const,
pressDrag: false,
}
expect(isCandidateInHotSet(scope, surfaceClass, floor)).toBe(true)
expect(isCandidateInHotSet(scope, surfaceClass, ceilingFan)).toBe(false)
})
test('moving a wall-mounted item: only walls', () => {
const scope = {
kind: 'moving' as const,
nodeId: 'w1',
nodeType: 'window',
view: '2d' as const,
}
expect(isCandidateInHotSet(scope, 'wall', wall)).toBe(true)
expect(isCandidateInHotSet(scope, 'wall', table)).toBe(false)
})
test('non-placement active scopes target nothing in the scene', () => {
expect(isCandidateInHotSet({ kind: 'box-select' }, null, floor)).toBe(false)
expect(
isCandidateInHotSet({ kind: 'handle-drag', nodeId: 'x', handle: 'h' }, null, floor),
).toBe(false)
})
})
@@ -0,0 +1,67 @@
// The hot-set: which scene objects are raycast-eligible during an interaction.
//
// It is never hand-authored per interaction. It falls out of the node's
// `asset.attachTo` plus whether a candidate exposes a top surface. "Floor item"
// really means surface-resting: it rests on the floor *or* any host's top
// surface. Walls and ceilings are the special attach modes. Adding a node kind
// = set `attachTo` (or leave blank); the hot-set follows with zero per-kind
// wiring.
import type { InteractionScope } from './scope'
// What a node attaches to, collapsed to the three classes the hot-set cares
// about. `wall-side` is a wall attachment; everything without an explicit
// `attachTo` is surface-resting.
export type AttachClass = 'wall' | 'ceiling' | 'surface'
export function attachClassOf(attachTo: string | undefined | null): AttachClass {
if (attachTo === 'wall' || attachTo === 'wall-side') return 'wall'
if (attachTo === 'ceiling') return 'ceiling'
return 'surface'
}
// The metadata the hot-set needs about a candidate host/surface. Derived from
// the candidate node + its registry definition by the caller, so this module
// stays pure and unit-testable without the scene or registry.
export type HotSetCandidate = {
type: string
// The level floor plane / ground a surface-resting node can always rest on.
isFloorLike: boolean
// The candidate exposes a usable top surface (registry
// `capabilities.surfaces.top`) — a table, a shelf, a slab.
exposesTop: boolean
// The candidate's own attach class. A ceiling fan is `ceiling`: it hangs from
// the ceiling and must never act as a host top (Track E).
attachClass: AttachClass
}
// For a node whose attach class is `placed`, is `candidate` a valid
// host/surface to pick during placement or move?
export function isPickableForAttach(placed: AttachClass, candidate: HotSetCandidate): boolean {
if (placed === 'wall') return candidate.type === 'wall'
if (placed === 'ceiling') return candidate.type === 'ceiling'
// Surface-resting: the floor, or any host that exposes a top surface — but
// never a ceiling-mounted host (a floor lamp must not land on a ceiling fan).
if (candidate.isFloorLike) return true
if (!candidate.exposesTop) return false
if (candidate.attachClass === 'ceiling') return false
return true
}
// The hot-set predicate for a whole scope. For placing/moving it derives from
// the moving node's attach class; for every other active scope nothing in the
// scene is a placement target, so the body's own raycast owns the pointer.
// `idle` returns true here — selection/phase filtering stays in the selection
// manager; this only narrows what an *active* interaction can target.
export function isCandidateInHotSet(
scope: InteractionScope,
placedAttachClass: AttachClass | null,
candidate: HotSetCandidate,
): boolean {
if (scope.kind === 'idle') return true
if (scope.kind === 'placing' || scope.kind === 'moving') {
if (placedAttachClass === null) return true
return isPickableForAttach(placedAttachClass, candidate)
}
return false
}
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test'
import { resolveOverlayPolicy } from './overlay-policy'
import type { ActiveInteractionScope } from './scope'
const ACTIVE_SCOPES: ActiveInteractionScope[] = [
{ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: false },
{ kind: 'moving', nodeId: 'i1', nodeType: 'item', view: '2d' },
{ kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
{ kind: 'drafting', tool: 'wall' },
{ kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 },
{ kind: 'box-select' },
{ kind: 'painting' },
]
describe('resolveOverlayPolicy', () => {
test('idle keeps everything shown and pickable', () => {
const p = resolveOverlayPolicy({ kind: 'idle' })
expect(p.zoneLabels).toBe('shown')
expect(p.contextBadges).toBe('shown')
expect(p.conflictingControls).toBe('shown')
expect(p.sceneObjectsPickable).toBe(true)
})
test('every active scope hides zone labels, fades badges, hides conflicting controls', () => {
for (const scope of ACTIVE_SCOPES) {
const p = resolveOverlayPolicy(scope)
expect(p.zoneLabels).toBe('hidden')
expect(p.contextBadges).toBe('faded')
expect(p.conflictingControls).toBe('hidden')
expect(p.sceneObjectsPickable).toBe(false)
}
})
test('active affordances and the contextual HUD always stay interactive', () => {
for (const scope of [{ kind: 'idle' } as const, ...ACTIVE_SCOPES]) {
const p = resolveOverlayPolicy(scope)
expect(p.activeAffordances).toBe('shown')
expect(p.contextualHudInteractive).toBe(true)
}
})
})
@@ -0,0 +1,59 @@
// The overlay scope matrix — the "Sims-light" feel. During any non-idle
// interaction, two layers behave differently:
//
// - 3D scene objects stay VISIBLE but become NON-pickable (the hot-set owns
// what the active interaction can target). Context is preserved; you just
// can't grab the wrong thing.
// - DOM/HUD overlays step back, differentiated by how distracting they are:
// zone labels -> hidden (not a primary editing concern)
// context badges -> faded + pointer-events:none (hover name pills)
// other controls -> hard-hidden (other objects' handles, the floating
// action menu, conflicting controls)
//
// The active interaction's own affordances (ghost, snap guides, dimension
// labels, the active handle) always stay — "default-off, opt-in for the active
// action". The contextual control HUD is exempt from the pointer-events
// step-back because it *is* the active interaction's own controls.
import { type InteractionScope, isActive } from './scope'
export type OverlayVisibility = 'shown' | 'faded' | 'hidden'
export type OverlayPolicy = {
zoneLabels: OverlayVisibility
// Hover name pills / context badges.
contextBadges: OverlayVisibility
// Other objects' handles + the floating action menu — anything whose action
// would conflict with the active interaction.
conflictingControls: OverlayVisibility
// Non-active scene objects: visible always, pickable only when idle.
sceneObjectsPickable: boolean
// The active interaction's own ghost/guides/dimension labels/handle. Always
// shown; this field exists so consumers can assert the contract.
activeAffordances: 'shown'
// The contextual control HUD keeps pointer events even while everything else
// steps back, because it is the active interaction's own controls.
contextualHudInteractive: boolean
}
const IDLE_POLICY: OverlayPolicy = {
zoneLabels: 'shown',
contextBadges: 'shown',
conflictingControls: 'shown',
sceneObjectsPickable: true,
activeAffordances: 'shown',
contextualHudInteractive: true,
}
const ACTIVE_POLICY: OverlayPolicy = {
zoneLabels: 'hidden',
contextBadges: 'faded',
conflictingControls: 'hidden',
sceneObjectsPickable: false,
activeAffordances: 'shown',
contextualHudInteractive: true,
}
export function resolveOverlayPolicy(scope: InteractionScope): OverlayPolicy {
return isActive(scope) ? ACTIVE_POLICY : IDLE_POLICY
}
@@ -0,0 +1,79 @@
// The authoritative description of "what the user is currently doing".
//
// Before this, that question was answered by re-deriving from 7+ independent
// `useEditor` flags (`mode`, `tool`, `movingNode`, `placementDragMode`,
// `activeHandleDrag`, `curvingWall`, `curvingFence`, `editingHole`,
// `movingWallEndpoint`, `movingFenceEndpoint`, …). Every overlay and pick site
// re-derived its behaviour from a different subset, so the flags could drift
// into illegal combinations (moving + curving at once; a stale `movingNode`
// after a drag ended). Collapsing them into one discriminated union makes those
// combinations unrepresentable: a scope is exactly one interaction at a time,
// and `idle` carries no interaction payload at all.
export type InteractionView = '2d' | '3d'
// Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
// (rather than four sibling scopes) keeps the union small while still making
// "curving and hole-editing at once" unrepresentable.
export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary'
export type InteractionScope =
| { kind: 'idle' }
// Placing a fresh node (catalog/preset/build tool). `pressDrag` is the
// gizmo press-drag flavour (commit on release) vs click-to-place.
| {
kind: 'placing'
nodeId: string
nodeType: string
view: InteractionView
pressDrag: boolean
}
// Moving an existing node.
| { kind: 'moving'; nodeId: string; nodeType: string; view: InteractionView }
// Dragging a resize/translate/rotate handle of a selected node.
| { kind: 'handle-drag'; nodeId: string; handle: string }
// Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
| { kind: 'drafting'; tool: string }
// Reshaping a selected node's geometry (see ReshapeKind).
| { kind: 'reshaping'; nodeId: string; reshape: ReshapeKind; holeIndex?: number }
// Marquee selection drag.
| { kind: 'box-select' }
// Material paint application.
| { kind: 'painting' }
export type InteractionKind = InteractionScope['kind']
export type ActiveInteractionScope = Exclude<InteractionScope, { kind: 'idle' }>
export const IDLE_SCOPE: InteractionScope = { kind: 'idle' }
export function isIdle(scope: InteractionScope): scope is { kind: 'idle' } {
return scope.kind === 'idle'
}
export function isActive(scope: InteractionScope): scope is ActiveInteractionScope {
return scope.kind !== 'idle'
}
// The node a scope is acting on, if any. Drafting/box-select/painting/idle
// target no single existing node.
export function scopeNodeId(scope: InteractionScope): string | null {
switch (scope.kind) {
case 'placing':
case 'moving':
case 'handle-drag':
case 'reshaping':
return scope.nodeId
default:
return null
}
}
// Selection/hover picking is only meaningful while idle. During any active
// interaction the pointer belongs to that interaction's body, not to selecting
// a different object — the picking choke point should not route a hover/click
// to selection while this is false.
export function selectionEnabled(scope: InteractionScope): boolean {
return scope.kind === 'idle'
}
@@ -40,4 +40,61 @@ describe('resolvePlanarCursorPosition', () => {
expect(moved.point).toEqual([11, 19]) expect(moved.point).toEqual([11, 19])
expect(moved.anchor).toEqual([4.1, 6.1]) expect(moved.anchor).toEqual([4.1, 6.1])
}) })
// Track B regression: "off-slab cursor, on-slab footprint stays at center".
// When the gizmo is grabbed off the footprint center (e.g. near a slab edge),
// the resolved center must track original + cursorDelta and be independent of
// the initial grab offset — so a footprint fully inside a slab cannot be
// pushed off the edge just because the cursor sample landed off-center.
test('relative mode cancels the off-center gizmo grab offset so the committed center is offset-independent', () => {
const original: [number, number] = [2, 2]
const firstSample: [number, number] = [2.3, 2.3]
const cursor: [number, number] = [3.1, 1.6]
const start = resolvePlanarCursorPosition({
cursor: firstSample,
original,
anchor: null,
mode: 'relative',
})
// First sample absorbs the off-center grab: the footprint stays put.
expect(start.point).toEqual(original)
expect(start.anchor).toEqual(firstSample)
const moved = resolvePlanarCursorPosition({
cursor,
original,
anchor: start.anchor,
mode: 'relative',
})
// Committed center = original + (cursor - firstSample), i.e. the gizmo
// offset is cancelled regardless of where on the footprint it was grabbed.
const expected: [number, number] = [
original[0] + (cursor[0] - firstSample[0]),
original[1] + (cursor[1] - firstSample[1]),
]
expect(moved.point[0]).toBeCloseTo(expected[0])
expect(moved.point[1]).toBeCloseTo(expected[1])
// The result must not depend on the absolute grab offset: grabbing the same
// footprint dead-center and moving by the same delta yields the same center.
const centerStart = resolvePlanarCursorPosition({
cursor: original,
original,
anchor: null,
mode: 'relative',
})
const delta: [number, number] = [cursor[0] - firstSample[0], cursor[1] - firstSample[1]]
const centerMoved = resolvePlanarCursorPosition({
cursor: [original[0] + delta[0], original[1] + delta[1]],
original,
anchor: centerStart.anchor,
mode: 'relative',
})
expect(centerMoved.point[0]).toBeCloseTo(moved.point[0])
expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
})
}) })
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'bun:test'
import {
DEFAULT_SNAPPING_MODE,
nextSnappingMode,
resolveSnapFlags,
SNAPPING_MODES,
} from './snapping-mode'
describe('resolveSnapFlags', () => {
it('default mode is grid', () => {
expect(DEFAULT_SNAPPING_MODE).toBe('grid')
})
it("default 'grid' reproduces today's full snapping (grid + magnetic + angles on)", () => {
expect(resolveSnapFlags('grid')).toEqual({ grid: true, magnetic: true, angles: true })
})
it("'off' disables grid, magnetic, and angles", () => {
expect(resolveSnapFlags('off')).toEqual({ grid: false, magnetic: false, angles: false })
})
it("'lines' keeps magnetic but drops the grid lattice and angle lock", () => {
expect(resolveSnapFlags('lines')).toEqual({ grid: false, magnetic: true, angles: false })
})
it("'angles' keeps the angle lock but drops grid and magnetic", () => {
expect(resolveSnapFlags('angles')).toEqual({ grid: false, magnetic: false, angles: true })
})
it("'lines' and 'angles' are distinct", () => {
expect(resolveSnapFlags('lines')).not.toEqual(resolveSnapFlags('angles'))
})
it('cycles through every mode and wraps', () => {
const seen = [DEFAULT_SNAPPING_MODE]
let mode = DEFAULT_SNAPPING_MODE
for (let i = 0; i < SNAPPING_MODES.length - 1; i += 1) {
mode = nextSnappingMode(mode)
seen.push(mode)
}
expect(seen).toEqual(SNAPPING_MODES)
expect(nextSnappingMode(mode)).toBe(DEFAULT_SNAPPING_MODE)
})
})
+58
View File
@@ -0,0 +1,58 @@
/**
* Snapping mode is a single global, user-cyclable control that maps onto the
* two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`).
* The default `'grid'` resolves to the exact pair the editor shipped with
* before this control existed (grid on, magnetic on), so the default path is
* behaviourally unchanged — only when a user opts into `'lines'` or `'off'`
* does any snap math get suppressed.
*/
export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off'
export const SNAPPING_MODES: SnappingMode[] = ['grid', 'lines', 'angles', 'off']
export const DEFAULT_SNAPPING_MODE: SnappingMode = 'grid'
export type SnapFlags = {
grid: boolean
magnetic: boolean
angles: boolean
}
/**
* Pure mapping from the curated mode enum onto the individual snap knobs.
*
* - `grid` → grid + magnetic + angles (today's default; full snapping).
* - `lines` → magnetic only (alignment / wall beacons, no grid lattice, no
* angle lock).
* - `angles` → angle lock only (15° wall/line rays, no grid lattice, no
* magnetic beacons).
* - `off` → nothing snaps.
*/
export function resolveSnapFlags(mode: SnappingMode): SnapFlags {
switch (mode) {
case 'grid':
return { grid: true, magnetic: true, angles: true }
case 'lines':
return { grid: false, magnetic: true, angles: false }
case 'angles':
return { grid: false, magnetic: false, angles: true }
case 'off':
return { grid: false, magnetic: false, angles: false }
}
}
const SNAPPING_MODE_LABELS: Record<SnappingMode, string> = {
grid: 'Grid',
lines: 'Lines',
angles: 'Angles',
off: 'Off',
}
export function getSnappingModeLabel(mode: SnappingMode): string {
return SNAPPING_MODE_LABELS[mode]
}
export function nextSnappingMode(mode: SnappingMode): SnappingMode {
const index = SNAPPING_MODES.indexOf(mode)
return SNAPPING_MODES[(index + 1) % SNAPPING_MODES.length] ?? DEFAULT_SNAPPING_MODE
}
+2 -2
View File
@@ -19,7 +19,7 @@ import {
type WallSnapRadii, type WallSnapRadii,
} from '../components/tools/wall/wall-drafting' } from '../components/tools/wall/wall-drafting'
import useAlignmentGuides from '../store/use-alignment-guides' import useAlignmentGuides from '../store/use-alignment-guides'
import useEditor from '../store/use-editor' import { isMagneticSnapActive } from '../store/use-editor'
import useWallSnapIndicator from '../store/use-wall-snap-indicator' import useWallSnapIndicator from '../store/use-wall-snap-indicator'
const SURFACE_SNAP_MOVING_ID = '__surface_snap__' const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
@@ -181,7 +181,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
const nodes = input.nodes ?? useScene.getState().nodes const nodes = input.nodes ?? useScene.getState().nodes
const walls = getLevelWalls(nodes, input.levelId, input.walls) const walls = getLevelWalls(nodes, input.levelId, input.walls)
const fallbackPoint = input.fallbackPoint const fallbackPoint = input.fallbackPoint
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap const magnetic = input.magnetic ?? isMagneticSnapActive()
const wallSnap = snapWallDraftPointDetailed({ const wallSnap = snapWallDraftPointDetailed({
point: input.rawPoint, point: input.rawPoint,
+164 -17
View File
@@ -40,6 +40,14 @@ import {
resolvePaintTargetFromSelection, resolvePaintTargetFromSelection,
type SingleSurfaceMaterialRole, type SingleSurfaceMaterialRole,
} from '../lib/material-paint' } from '../lib/material-paint'
import {
DEFAULT_SNAPPING_MODE,
nextSnappingMode,
resolveSnapFlags,
SNAPPING_MODES,
type SnappingMode,
} from '../lib/snapping-mode'
import useInteractionScope from './use-interaction-scope'
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
@@ -374,11 +382,20 @@ type EditorState = {
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep gridSnapStep: GridSnapStep
setGridSnapStep: (step: GridSnapStep) => void setGridSnapStep: (step: GridSnapStep) => void
// Cycles the grid step through GRID_SNAP_STEPS (0.5 → 0.25 → 0.1 → 0.05 →
// 0.5) and returns the new value. Bound to the measurement-step shortcut.
cycleGridSnapStep: () => GridSnapStep
// Magnetic snapping while drafting — snaps wall endpoints onto existing // Magnetic snapping while drafting — snaps wall endpoints onto existing
// wall corners / wall bodies (the "magnetic" beacon). Independent of grid // wall corners / wall bodies (the "magnetic" beacon). Independent of grid
// snap. On by default; toggled from the Display menu. // snap. On by default; toggled from the Display menu.
magneticSnap: boolean magneticSnap: boolean
setMagneticSnap: (enabled: boolean) => void setMagneticSnap: (enabled: boolean) => void
// Global, user-cyclable snapping mode. Maps onto `gridSnapStep` (grid) and
// `magneticSnap` via `resolveSnapFlags`. Default `'grid'` reproduces the
// historical behaviour (grid + magnetic on).
snappingMode: SnappingMode
setSnappingMode: (mode: SnappingMode) => void
cycleSnappingMode: () => SnappingMode
showReferenceFloor: boolean showReferenceFloor: boolean
toggleReferenceFloor: () => void toggleReferenceFloor: () => void
setShowReferenceFloor: (show: boolean) => void setShowReferenceFloor: (show: boolean) => void
@@ -427,6 +444,7 @@ type PersistedEditorLayoutState = Pick<
| 'floorplanSelectionTool' | 'floorplanSelectionTool'
| 'gridSnapStep' | 'gridSnapStep'
| 'magneticSnap' | 'magneticSnap'
| 'snappingMode'
| 'showReferenceFloor' | 'showReferenceFloor'
| 'referenceFloorOffset' | 'referenceFloorOffset'
| 'referenceFloorOpacity' | 'referenceFloorOpacity'
@@ -450,6 +468,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
floorplanSelectionTool: 'click', floorplanSelectionTool: 'click',
gridSnapStep: 0.5, gridSnapStep: 0.5,
magneticSnap: true, magneticSnap: true,
snappingMode: DEFAULT_SNAPPING_MODE,
showReferenceFloor: false, showReferenceFloor: false,
referenceFloorOffset: 1, referenceFloorOffset: 1,
referenceFloorOpacity: 0.35, referenceFloorOpacity: 0.35,
@@ -568,6 +587,9 @@ function normalizePersistedEditorLayoutState(
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
// Default on: only an explicit persisted `false` disables it. // Default on: only an explicit persisted `false` disables it.
magneticSnap: state?.magneticSnap !== false, magneticSnap: state?.magneticSnap !== false,
snappingMode: SNAPPING_MODES.includes(state?.snappingMode as SnappingMode)
? (state?.snappingMode as SnappingMode)
: DEFAULT_SNAPPING_MODE,
showReferenceFloor: state?.showReferenceFloor === true, showReferenceFloor: state?.showReferenceFloor === true,
referenceFloorOffset: referenceFloorOffset:
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -760,6 +782,10 @@ const useEditor = create<EditorState>()(
else if (tool) { else if (tool) {
set({ tool: null }) set({ tool: null })
} }
const scope = useInteractionScope.getState()
if (mode === 'material-paint') scope.begin({ kind: 'painting' })
else scope.endIf((s) => s.kind === 'painting')
}, },
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool, tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
setTool: (tool) => set({ tool }), setTool: (tool) => set({ tool }),
@@ -814,25 +840,68 @@ const useEditor = create<EditorState>()(
| null, | null,
placementDragMode: false, placementDragMode: false,
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }), setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
setMovingNode: (node) => setMovingNode: (node) => {
set( const scope = useInteractionScope.getState()
node === null if (node === null) {
? // Preserve `movingNodeOrigin` across the clear so the scope.endIf((s) => s.kind === 'placing' || s.kind === 'moving')
// non-owning side's effect cleanup — which fires after // Preserve `movingNodeOrigin` across the clear so the non-owning
// `setMovingNode(null)` propagates — can still read who // side's effect cleanup — which fires after `setMovingNode(null)`
// finalised. The next non-null `setMovingNode` resets it. // propagates — can still read who finalised. The next non-null
// Always clear the press-drag flag when a move ends. // `setMovingNode` resets it. Always clear the press-drag flag.
{ movingNode: null, placementDragMode: false } set({ movingNode: null, placementDragMode: false })
: { movingNode: node, movingNodeOrigin: null }, return
), }
const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew)
if (isNew) {
scope.begin({
kind: 'placing',
nodeId: node.id,
nodeType: node.type,
view: '3d',
pressDrag: get().placementDragMode,
})
} else {
scope.begin({ kind: 'moving', nodeId: node.id, nodeType: node.type, view: '3d' })
}
set({ movingNode: node, movingNodeOrigin: null })
},
movingNodeOrigin: null as '2d' | '3d' | null, movingNodeOrigin: null as '2d' | '3d' | null,
setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }), setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
movingWallEndpoint: null, movingWallEndpoint: null,
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }), setMovingWallEndpoint: (value) => {
const scope = useInteractionScope.getState()
if (value) scope.begin({ kind: 'reshaping', nodeId: value.wall.id, reshape: 'endpoint' })
else {
const prev = get().movingWallEndpoint
if (prev)
scope.endIf(
(s) =>
s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.wall.id,
)
}
set({ movingWallEndpoint: value })
},
movingFenceEndpoint: null, movingFenceEndpoint: null,
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }), setMovingFenceEndpoint: (value) => {
const scope = useInteractionScope.getState()
if (value) scope.begin({ kind: 'reshaping', nodeId: value.fence.id, reshape: 'endpoint' })
else {
const prev = get().movingFenceEndpoint
if (prev)
scope.endIf(
(s) =>
s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.fence.id,
)
}
set({ movingFenceEndpoint: value })
},
activeHandleDrag: null, activeHandleDrag: null,
setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }), setActiveHandleDrag: (drag) => {
const scope = useInteractionScope.getState()
if (drag) scope.begin({ kind: 'handle-drag', nodeId: drag.nodeId, handle: drag.label })
else scope.endIf((s) => s.kind === 'handle-drag')
set({ activeHandleDrag: drag })
},
rotationAxis: 'y', rotationAxis: 'y',
cycleRotationAxis: () => { cycleRotationAxis: () => {
const order = ['y', 'x', 'z'] as const const order = ['y', 'x', 'z'] as const
@@ -841,9 +910,31 @@ const useEditor = create<EditorState>()(
return next return next
}, },
curvingWall: null, curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }), setCurvingWall: (wall) => {
const scope = useInteractionScope.getState()
if (wall) scope.begin({ kind: 'reshaping', nodeId: wall.id, reshape: 'curve' })
else {
const prev = get().curvingWall
if (prev)
scope.endIf(
(s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id,
)
}
set({ curvingWall: wall })
},
curvingFence: null, curvingFence: null,
setCurvingFence: (fence) => set({ curvingFence: fence }), setCurvingFence: (fence) => {
const scope = useInteractionScope.getState()
if (fence) scope.begin({ kind: 'reshaping', nodeId: fence.id, reshape: 'curve' })
else {
const prev = get().curvingFence
if (prev)
scope.endIf(
(s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id,
)
}
set({ curvingFence: fence })
},
selectedMaterialTarget: null, selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
activePaintMaterial: null, activePaintMaterial: null,
@@ -925,7 +1016,24 @@ const useEditor = create<EditorState>()(
spaces: {}, spaces: {},
setSpaces: (spaces) => set({ spaces }), setSpaces: (spaces) => set({ spaces }),
editingHole: null, editingHole: null,
setEditingHole: (hole) => set({ editingHole: hole }), setEditingHole: (hole) => {
const scope = useInteractionScope.getState()
if (hole)
scope.begin({
kind: 'reshaping',
nodeId: hole.nodeId,
reshape: 'hole',
holeIndex: hole.holeIndex,
})
else {
const prev = get().editingHole
if (prev)
scope.endIf(
(s) => s.kind === 'reshaping' && s.reshape === 'hole' && s.nodeId === prev.nodeId,
)
}
set({ editingHole: hole })
},
hoveredHole: null, hoveredHole: null,
setHoveredHole: (hole) => setHoveredHole: (hole) =>
set((state) => set((state) =>
@@ -1007,8 +1115,22 @@ const useEditor = create<EditorState>()(
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
setGridSnapStep: (step) => set({ gridSnapStep: step }), setGridSnapStep: (step) => set({ gridSnapStep: step }),
cycleGridSnapStep: () => {
const current = get().gridSnapStep
const index = GRID_SNAP_STEPS.indexOf(current)
const next = GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
set({ gridSnapStep: next })
return next
},
magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap, magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
setMagneticSnap: (enabled) => set({ magneticSnap: enabled }), setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
snappingMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingMode,
setSnappingMode: (mode) => set({ snappingMode: mode }),
cycleSnappingMode: () => {
const next = nextSnappingMode(get().snappingMode)
set({ snappingMode: next })
return next
},
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
toggleReferenceFloor: () => toggleReferenceFloor: () =>
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })), set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -1101,6 +1223,7 @@ const useEditor = create<EditorState>()(
floorplanSelectionTool: state.floorplanSelectionTool, floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep, gridSnapStep: state.gridSnapStep,
magneticSnap: state.magneticSnap, magneticSnap: state.magneticSnap,
snappingMode: state.snappingMode,
showReferenceFloor: state.showReferenceFloor, showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset, referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity, referenceFloorOpacity: state.referenceFloorOpacity,
@@ -1109,4 +1232,28 @@ const useEditor = create<EditorState>()(
), ),
) )
/**
* Effective magnetic-snap state: the legacy `magneticSnap` flag AND the
* snapping mode's magnetic component. Default mode `'grid'` resolves magnetic
* to `true`, so with the default-on `magneticSnap` this returns `true` exactly
* as before; only `'off'` (or an explicitly-disabled `magneticSnap`) turns it
* off. Read from the smallest magnetic choke points so the mode is honoured
* without retuning any snap math.
*/
export function isMagneticSnapActive(): boolean {
const state = useEditor.getState()
return state.magneticSnap && resolveSnapFlags(state.snappingMode).magnetic
}
/**
* Effective angle-lock state: the snapping mode's angle component. Default mode
* `'grid'` resolves angles to `true`, so the 15° draft lock behaves exactly as
* before; `'lines'` and `'off'` suppress it. Read from the smallest angle-lock
* choke points (wall / fence draft call sites) so the mode is honoured without
* retuning any snap math.
*/
export function isAngleSnapActive(): boolean {
return resolveSnapFlags(useEditor.getState().snappingMode).angles
}
export default useEditor export default useEditor
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { isActive, isIdle, scopeNodeId, selectionEnabled } from '../lib/interaction/scope'
import useInteractionScope from './use-interaction-scope'
function reset() {
useInteractionScope.getState().end()
}
afterEach(reset)
describe('use-interaction-scope state machine', () => {
test('starts idle', () => {
expect(useInteractionScope.getState().scope.kind).toBe('idle')
expect(isIdle(useInteractionScope.getState().scope)).toBe(true)
})
test('begin enters an interaction; end returns to idle atomically', () => {
const s = useInteractionScope.getState()
s.begin({ kind: 'moving', nodeId: 'item_1', nodeType: 'item', view: '3d' })
expect(useInteractionScope.getState().scope).toEqual({
kind: 'moving',
nodeId: 'item_1',
nodeType: 'item',
view: '3d',
})
s.end()
// No interaction payload leaks past end — the scope is plain idle, so a
// stale nodeId/handle is unrepresentable.
expect(useInteractionScope.getState().scope).toEqual({ kind: 'idle' })
expect(scopeNodeId(useInteractionScope.getState().scope)).toBeNull()
})
test('begin is single-owner: a new interaction replaces the prior one', () => {
const s = useInteractionScope.getState()
s.begin({ kind: 'drafting', tool: 'wall' })
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
const scope = useInteractionScope.getState().scope
expect(scope.kind).toBe('handle-drag')
// The prior drafting payload is gone — illegal "drafting + handle-drag"
// combination is unrepresentable.
expect(scopeNodeId(scope)).toBe('wall_1')
})
test('update patches the live payload of the active scope', () => {
const s = useInteractionScope.getState()
s.begin({ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: false })
s.update({ pressDrag: true })
const scope = useInteractionScope.getState().scope
expect(scope.kind === 'placing' && scope.pressDrag).toBe(true)
})
test('update is a no-op when idle', () => {
useInteractionScope
.getState()
.update({ kind: 'moving', nodeId: 'x', nodeType: 'item', view: '3d' })
expect(useInteractionScope.getState().scope.kind).toBe('idle')
})
test('update cannot change which interaction is running', () => {
const s = useInteractionScope.getState()
s.begin({ kind: 'moving', nodeId: 'i1', nodeType: 'item', view: '3d' })
s.update({ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: true })
expect(useInteractionScope.getState().scope.kind).toBe('moving')
})
test('selectionEnabled only while idle', () => {
const s = useInteractionScope.getState()
expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(true)
s.begin({ kind: 'box-select' })
expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(false)
expect(isActive(useInteractionScope.getState().scope)).toBe(true)
})
test('end is idempotent', () => {
const s = useInteractionScope.getState()
s.end()
s.end()
expect(useInteractionScope.getState().scope.kind).toBe('idle')
})
})
@@ -0,0 +1,55 @@
'use client'
import { create } from 'zustand'
import {
type ActiveInteractionScope,
IDLE_SCOPE,
type InteractionScope,
} from '../lib/interaction/scope'
// The authoritative interaction state machine. A single owner holds exactly one
// scope at a time. `begin` enters an interaction (atomically replacing any prior
// one — a single owner, no producer races), `update` narrows the live payload,
// and `end` returns to idle atomically so no interaction payload can leak past
// the end of its interaction. There is no setter that can leave the store in an
// illegal half-state: the only writable shape is `InteractionScope`.
export type InteractionScopeState = {
scope: InteractionScope
// Enter an interaction. If one is already active it is ended first, so the
// store is always single-owner.
begin: (scope: ActiveInteractionScope) => void
// Patch the current scope's payload. Ignored when idle, or when the patch's
// implied kind differs from the active kind — payload updates must not change
// which interaction is running (use `begin` for that).
update: (patch: Partial<ActiveInteractionScope>) => void
// Return to idle atomically. Both commit and cancel paths call this; the
// distinction (write vs revert) lives in the interaction body, not here.
end: () => void
// Return to idle only if the active scope matches `match`. Used when scope is
// driven from independent legacy flag clears, so clearing one flag (e.g. a
// fence curve) cannot stomp an unrelated active scope (e.g. a wall move).
endIf: (match: (scope: ActiveInteractionScope) => boolean) => void
}
const useInteractionScope = create<InteractionScopeState>((set, get) => ({
scope: IDLE_SCOPE,
begin: (scope) => set({ scope }),
update: (patch) =>
set((state) => {
if (state.scope.kind === 'idle') return state
if ('kind' in patch && patch.kind !== state.scope.kind) return state
return { scope: { ...state.scope, ...patch } as InteractionScope }
}),
end: () => {
if (get().scope.kind === 'idle') return
set({ scope: IDLE_SCOPE })
},
endIf: (match) => {
const scope = get().scope
if (scope.kind === 'idle') return
if (match(scope)) set({ scope: IDLE_SCOPE })
},
}))
export default useInteractionScope
+1
View File
@@ -223,6 +223,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute // Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
// direction + perpendicular for the cutout footprint. // direction + perpendicular for the cutout footprint.
floorplan: buildDoorFloorplan, floorplan: buildDoorFloorplan,
floorplanDependsOnSiblings: true,
// Stage D — placement (`def.tool`) + move-on-wall (`def. // Stage D — placement (`def.tool`) + move-on-wall (`def.
// affordanceTools.move`). Both ports of the legacy tools at // affordanceTools.move`). Both ports of the legacy tools at
// `editor/components/tools/door/`, relocated into the kind folder and // `editor/components/tools/door/`, relocated into the kind folder and
@@ -11,6 +11,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
type FencePlanPoint, type FencePlanPoint,
isMagneticSnapActive,
isSegmentLongEnough, isSegmentLongEnough,
snapFenceDraftPoint, snapFenceDraftPoint,
useAlignmentGuides, useAlignmentGuides,
@@ -171,6 +172,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
fences: ctx.levelFences, fences: ctx.levelFences,
ignoreFenceIds: [ctx.fenceId as string], ignoreFenceIds: [ctx.fenceId as string],
bypassSnap: modifiers.shift, bypassSnap: modifiers.shift,
magnetic: !modifiers.shift && isMagneticSnapActive(),
}) })
// Figma-style alignment: nudge the dragged endpoint onto another wall / // Figma-style alignment: nudge the dragged endpoint onto another wall /
-1
View File
@@ -229,7 +229,6 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Set fence start / end' }, { key: 'Left click', label: 'Set fence start / end' },
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
@@ -15,6 +15,7 @@ import {
alignFloorplanDraftPoint, alignFloorplanDraftPoint,
type FencePlanPoint, type FencePlanPoint,
getSegmentGridStep, getSegmentGridStep,
isMagneticSnapActive,
isSegmentLongEnough, isSegmentLongEnough,
snapBuildingLocalToWorldGrid, snapBuildingLocalToWorldGrid,
snapFenceDraftPoint, snapFenceDraftPoint,
@@ -165,7 +166,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
fences: nextFences, fences: nextFences,
ignoreFenceIds: [node.id], ignoreFenceIds: [node.id],
bypassSnap: modifiers.shiftKey, bypassSnap: modifiers.shiftKey,
magnetic: !modifiers.shiftKey, magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint, gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
}) })
// Figma-style alignment on the dragged endpoint — snaps it onto // Figma-style alignment on the dragged endpoint — snaps it onto
+2
View File
@@ -15,6 +15,7 @@ import {
import { import {
CursorSphere, CursorSphere,
consumePlacementDragRelease, consumePlacementDragRelease,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
snapFenceDraftPoint, snapFenceDraftPoint,
triggerSFX, triggerSFX,
@@ -201,6 +202,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
fences: levelFences, fences: levelFences,
ignoreFenceIds: [fenceId], ignoreFenceIds: [fenceId],
bypassSnap, bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
}) })
if ( if (
+29 -37
View File
@@ -24,6 +24,8 @@ import {
getAngleArcToSegmentReference, getAngleArcToSegmentReference,
getAngleToSegmentReference, getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint, getSegmentAngleReferenceAtPoint,
isAngleSnapActive,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
type SegmentAngleReference, type SegmentAngleReference,
snapFenceDraftPoint, snapFenceDraftPoint,
@@ -445,7 +447,6 @@ export const FenceTool: React.FC = () => {
const startingPoint = useRef(new Vector3(0, 0, 0)) const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0) const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null) const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const measurementColor = isDark ? '#ffffff' : '#111111' const measurementColor = isDark ? '#ffffff' : '#111111'
const measurementShadowColor = isDark ? '#111111' : '#ffffff' const measurementShadowColor = isDark ? '#111111' : '#ffffff'
@@ -466,10 +467,13 @@ export const FenceTool: React.FC = () => {
} }
// Align the drafted point onto another object's nearest real anchor and // Align the drafted point onto another object's nearest real anchor and
// publish the guide. Alt bypasses alignment; Shift bypasses all guided // publish the guide. Alt bypasses alignment. Returns the possibly snapped
// snapping. Returns the possibly snapped point. // point.
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => { const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
if (bypass || alignmentCandidates.length === 0) { // Figma alignment pulls the endpoint onto existing corners / edges, so it
// is a line snap — suppress it whenever magnetic snap is off (`'off'` /
// `'angles'`), matching the fence-geometry snap.
if (bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return point return point
} }
@@ -494,13 +498,13 @@ export const FenceTool: React.FC = () => {
if (!(cursorRef.current && previewRef.current)) return if (!(cursorRef.current && previewRef.current)) return
const { walls, fences } = getCurrentLevelElements() const { walls, fences } = getCurrentLevelElements()
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
// While drafting, the segment locks to 15° rays from its start // While drafting, the segment locks to 15° rays from its start.
// unless Shift is held. Shift also bypasses grid and magnetic snap. // Snapping is governed by the snapping mode (`'off'` is the bypass);
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true // there is no Shift hold-to-bypass. Alt still bypasses alignment guides.
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap const bypassAlign = event.nativeEvent?.altKey === true
if (buildingState.current === 1) { if (buildingState.current === 1) {
const angleLocked = !bypassSnap const angleLocked = isAngleSnapActive()
const snappedLocal = alignPoint( const snappedLocal = alignPoint(
snapFenceDraftPoint({ snapFenceDraftPoint({
point: localPoint, point: localPoint,
@@ -508,7 +512,7 @@ export const FenceTool: React.FC = () => {
fences, fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked, angleSnap: angleLocked,
bypassSnap, magnetic: isMagneticSnapActive(),
}), }),
bypassAlign || angleLocked, bypassAlign || angleLocked,
) )
@@ -516,7 +520,6 @@ export const FenceTool: React.FC = () => {
cursorRef.current.position.copy(endingPoint.current) cursorRef.current.position.copy(endingPoint.current)
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
if ( if (
!bypassSnap &&
previousFenceEnd && previousFenceEnd &&
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1]) (currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
) { ) {
@@ -543,7 +546,12 @@ export const FenceTool: React.FC = () => {
) )
} else { } else {
const snappedPoint = alignPoint( const snappedPoint = alignPoint(
snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }), snapFenceDraftPoint({
point: localPoint,
walls,
fences,
magnetic: isMagneticSnapActive(),
}),
bypassAlign, bypassAlign,
) )
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
@@ -559,12 +567,16 @@ export const FenceTool: React.FC = () => {
const { walls, fences } = getCurrentLevelElements() const { walls, fences } = getCurrentLevelElements()
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true const bypassAlign = event.nativeEvent?.altKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
if (buildingState.current === 0) { if (buildingState.current === 0) {
const snappedStart = alignPoint( const snappedStart = alignPoint(
snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }), snapFenceDraftPoint({
point: localClick,
walls,
fences,
magnetic: isMagneticSnapActive(),
}),
bypassAlign, bypassAlign,
) )
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
@@ -574,7 +586,7 @@ export const FenceTool: React.FC = () => {
previewRef.current.visible = true previewRef.current.visible = true
setDraftMeasurement(null) setDraftMeasurement(null)
} else { } else {
const angleLocked = !bypassSnap const angleLocked = isAngleSnapActive()
const snappedEnd = alignPoint( const snappedEnd = alignPoint(
snapFenceDraftPoint({ snapFenceDraftPoint({
point: localClick, point: localClick,
@@ -582,7 +594,7 @@ export const FenceTool: React.FC = () => {
fences, fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked, angleSnap: angleLocked,
bypassSnap, magnetic: isMagneticSnapActive(),
}), }),
bypassAlign || angleLocked, bypassAlign || angleLocked,
) )
@@ -614,20 +626,6 @@ export const FenceTool: React.FC = () => {
} }
} }
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
// angle lock isn't stuck off when focus returns.
const onBlur = () => {
shiftPressed.current = false
}
const onCancel = () => { const onCancel = () => {
if (buildingState.current === 1) { if (buildingState.current === 1) {
markToolCancelConsumed() markToolCancelConsumed()
@@ -638,17 +636,11 @@ export const FenceTool: React.FC = () => {
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
useSegmentDraftChain.getState().clear('fence') useSegmentDraftChain.getState().clear('fence')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} }
+1
View File
@@ -162,6 +162,7 @@ export const gutterDefinition: NodeDefinition<typeof GutterNode> = {
parametrics: gutterParametrics, parametrics: gutterParametrics,
handles: gutterHandles, handles: gutterHandles,
floorplan: buildGutterFloorplan, floorplan: buildGutterFloorplan,
floorplanDependsOnSiblings: true,
renderer: { renderer: {
kind: 'parametric', kind: 'parametric',
+2 -1
View File
@@ -315,7 +315,8 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
{ key: 'Left click', label: 'Place item' }, { key: 'Left click', label: 'Place item' },
{ key: 'R', label: 'Rotate counterclockwise' }, { key: 'R', label: 'Rotate counterclockwise' },
{ key: 'T', label: 'Rotate clockwise' }, { key: 'T', label: 'Rotate clockwise' },
{ key: 'Shift', label: 'Free place' }, { key: 'Shift', label: 'Cycle snapping mode' },
{ key: 'Alt', label: 'Free place (no snap)' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
+5 -2
View File
@@ -182,6 +182,7 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
}) })
const isSelected = ctx.viewState?.selected ?? false const isSelected = ctx.viewState?.selected ?? false
const isMoving = ctx.viewState?.moving ?? false
const floorPlanUrl = node.asset.floorPlanUrl const floorPlanUrl = node.asset.floorPlanUrl
const children: FloorplanGeometry[] = [ const children: FloorplanGeometry[] = [
{ {
@@ -214,8 +215,10 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
rotation: transform.rotation, rotation: transform.rotation,
}) })
} }
// Move handle — orange dot at the item center. Only when selected. // Move handle — orange dot at the item center. Only when selected and not
if (isSelected) { // already moving: during a move the dot sits under the cursor, so a release
// over it would re-arm the move (and re-enter edit) instead of committing.
if (isSelected && !isMoving) {
children.push({ children.push({
kind: 'move-handle', kind: 'move-handle',
point: [cx, cy], point: [cx, cy],
+4 -1
View File
@@ -23,6 +23,7 @@ import {
consumePlacementDragRelease, consumePlacementDragRelease,
DragBoundingBox, DragBoundingBox,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
isMagneticSnapActive,
resolvePlanarCursorPosition, resolvePlanarCursorPosition,
snapFenceDraftPoint, snapFenceDraftPoint,
stripPlacementMetadataFlags, stripPlacementMetadataFlags,
@@ -290,11 +291,13 @@ export const MoveRoofTool: React.FC<{
const y = event.position[1] const y = event.position[1]
const roofBypassSnap = event.nativeEvent?.shiftKey === true
const snappedLocal = snapFenceDraftPoint({ const snappedLocal = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]], point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls, walls: levelWalls,
fences: levelFences, fences: levelFences,
bypassSnap: event.nativeEvent?.shiftKey === true, bypassSnap: roofBypassSnap,
magnetic: !roofBypassSnap && isMagneticSnapActive(),
}) })
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y) const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
const [rawLocalX, rawLocalZ] = computeLocal( const [rawLocalX, rawLocalZ] = computeLocal(
+2
View File
@@ -18,6 +18,7 @@ import {
CursorSphere, CursorSphere,
consumePlacementDragRelease, consumePlacementDragRelease,
getSegmentGridStep, getSegmentGridStep,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
resolveAlignmentForActiveBuilding, resolveAlignmentForActiveBuilding,
snapBuildingLocalToWorldGrid, snapBuildingLocalToWorldGrid,
@@ -174,6 +175,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
walls: levelWalls, walls: levelWalls,
fences: levelFences, fences: levelFences,
bypassSnap, bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
}) })
+7 -5
View File
@@ -1,5 +1,5 @@
import type { NodeDefinition } from '@pascal-app/core' import type { NodeDefinition } from '@pascal-app/core'
import { buildWallFloorplan } from './floorplan' import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances' import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
import { wallFloorplanMoveTarget } from './floorplan-move' import { wallFloorplanMoveTarget } from './floorplan-move'
import { wallFloorplanSiblingOverrides } from './floorplan-overrides' import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
@@ -18,7 +18,8 @@ import { wallSlots } from './slots'
* `renderer` + `system` keep wrap-exporting legacy WallRenderer + * `renderer` + `system` keep wrap-exporting legacy WallRenderer +
* WallSystem + WallCutout. * WallSystem + WallCutout.
* Stage C: `def.floorplan` builder produces the mitered plan footprint * Stage C: `def.floorplan` builder produces the mitered plan footprint
* polygon using `ctx.siblings` to assemble miter context. * polygon from shared floor-plan level data, with `ctx.siblings` as the
* direct-caller fallback.
* floorplan-panel.tsx's `wallPolygons` short-circuits to [] when * floorplan-panel.tsx's `wallPolygons` short-circuits to [] when
* wall is registered. * wall is registered.
*/ */
@@ -98,9 +99,11 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
// Priority 4 mirrors the legacy WallSystem's useFrame priority. // Priority 4 mirrors the legacy WallSystem's useFrame priority.
priority: 4, priority: 4,
}, },
// Stage C: floor-plan rendering. ctx.siblings provides other walls in // Stage C: floor-plan rendering. Precomputes the level miter graph once
// the level so `calculateLevelMiters` can compute correct corner joins. // per render pass, then the builder reads its own junctions by wall id.
computeFloorplanLevelData: computeWallFloorplanLevelData,
floorplan: buildWallFloorplan, floorplan: buildWallFloorplan,
floorplanDependsOnSiblings: true,
// 2D drag affordances triggered by `endpoint-handle` primitives in // 2D drag affordances triggered by `endpoint-handle` primitives in
// `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the // `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the
// same legacy `MoveWallEndpointTool` flow, reachable from both the // same legacy `MoveWallEndpointTool` flow, reachable from both the
@@ -114,7 +117,6 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Set wall start / end' }, { key: 'Left click', label: 'Set wall start / end' },
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
@@ -13,6 +13,7 @@ import {
import { import {
alignFloorplanDraftPoint, alignFloorplanDraftPoint,
getSegmentGridStep, getSegmentGridStep,
isMagneticSnapActive,
isSegmentLongEnough, isSegmentLongEnough,
snapBuildingLocalToWorldGrid, snapBuildingLocalToWorldGrid,
snapScalarToGrid, snapScalarToGrid,
@@ -193,7 +194,7 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
walls, walls,
ignoreWallIds: [node.id], ignoreWallIds: [node.id],
bypassSnap: modifiers.shiftKey, bypassSnap: modifiers.shiftKey,
magnetic: !modifiers.shiftKey, magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
}) })
// Figma-style alignment on the dragged corner — snaps it onto another // Figma-style alignment on the dragged corner — snaps it onto another
+27 -13
View File
@@ -8,6 +8,7 @@ import {
getWallMidpointHandlePoint, getWallMidpointHandlePoint,
getWallPlanFootprint, getWallPlanFootprint,
isCurvedWall, isCurvedWall,
type WallMiterData,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -35,6 +36,15 @@ function formatLengthMetric(meters: number): string {
return `${Number.parseFloat(meters.toFixed(2))}m` return `${Number.parseFloat(meters.toFixed(2))}m`
} }
export function computeWallFloorplanLevelData({
siblings,
}: {
siblings: ReadonlyArray<WallNode>
nodes: Record<string, AnyNode>
}): WallMiterData {
return calculateLevelMiters(siblings.map(exaggerateWallThickness))
}
/** /**
* Stage C floor-plan builder for wall — emits the full chrome stack the * Stage C floor-plan builder for wall — emits the full chrome stack the
* legacy `floorplan-panel.tsx` rendered inline: * legacy `floorplan-panel.tsx` rendered inline:
@@ -47,21 +57,25 @@ function formatLengthMetric(meters: number): string {
* layer hosts the 5-circle stack + hover transitions + 2D drag. * layer hosts the 5-circle stack + hover transitions + 2D drag.
* 5. A small dimension label at the midpoint when selected. * 5. A small dimension label at the midpoint when selected.
* *
* `ctx.siblings` provides other walls in the level so * `ctx.levelData` provides the shared level miter graph when the floor-plan
* `calculateLevelMiters` computes correct corner joins. * dispatcher precomputes it; `ctx.siblings` remains the fallback path for
* * direct builder callers.
* Performance note: this recomputes level miter data per wall (O(N²)
* across N walls in the level). For < 100 walls per level this is
* sub-millisecond. If a real perf hotspot surfaces, the
* `ctx.levelData?.miters` extension flagged in the plan moves the batch
* computation to the dispatcher.
*/ */
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null { export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
const siblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall') const self = exaggerateWallThickness(node)
const all = [node, ...siblings].map(exaggerateWallThickness) // Prefer the level-batch miter graph the floor-plan dispatcher precomputes
const miters = calculateLevelMiters(all) // once per pass (`computeWallFloorplanLevelData`). Only the fallback path —
const self = all.find((w) => w.id === node.id) // a direct builder caller with no shared data — pays the O(N) exaggerate +
if (!self) return null // level-wide miter calc per wall; the dispatcher path is O(1) here, which is
// what keeps a wall drag from being O(N²) across the level.
const miters =
(ctx.levelData as WallMiterData | undefined) ??
calculateLevelMiters([
self,
...ctx.siblings
.filter((s): s is AnyNode & WallNode => s.type === 'wall')
.map(exaggerateWallThickness),
])
const polygon = getWallPlanFootprint(self, miters) const polygon = getWallPlanFootprint(self, miters)
if (!polygon || polygon.length < 3) return null if (!polygon || polygon.length < 3) return null
@@ -19,6 +19,7 @@ import {
formatAngleRadians, formatAngleRadians,
getAngleToSegmentReference, getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint, getSegmentAngleReferenceAtPoint,
isMagneticSnapActive,
isSegmentLongEnough, isSegmentLongEnough,
MeasurementPill, MeasurementPill,
type MovingWallEndpoint, type MovingWallEndpoint,
@@ -295,7 +296,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
walls: levelWalls, walls: levelWalls,
ignoreWallIds: [nodeId], ignoreWallIds: [nodeId],
bypassSnap, bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap, magnetic: !bypassSnap && isMagneticSnapActive(),
}) })
const snappedPoint = snapResult.point const snappedPoint = snapResult.point
+20 -42
View File
@@ -20,6 +20,8 @@ import {
getAngleArcToSegmentReference, getAngleArcToSegmentReference,
getAngleToSegmentReference, getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint, getSegmentAngleReferenceAtPoint,
isAngleSnapActive,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
type SegmentAngleReference, type SegmentAngleReference,
snapWallDraftPointDetailed, snapWallDraftPointDetailed,
@@ -40,8 +42,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
* *
* 1:1 port of the legacy `WallTool`. Two-click flow: click 1 sets the * 1:1 port of the legacy `WallTool`. Two-click flow: click 1 sets the
* start, click 2 creates the wall. Between clicks a vertical preview * start, click 2 creates the wall. Between clicks a vertical preview
* rectangle + length/angle measurement HUD follow the pointer. Shift * rectangle + length/angle measurement HUD follow the pointer. Snapping is
* bypasses the angle snap; Esc cancels. * governed by the global snapping mode (`'off'` is the bypass); Esc cancels.
* *
* Not a `DragAction` — same reasoning as fence/slab/ceiling placement: * Not a `DragAction` — same reasoning as fence/slab/ceiling placement:
* stateful sequence of grid:click events, not a single drag-up. * stateful sequence of grid:click events, not a single drag-up.
@@ -486,7 +488,6 @@ export const WallTool: React.FC = () => {
const startingPoint = useRef(new Vector3(0, 0, 0)) const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0) const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null) const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null) const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
const measurementColor = isDark ? '#ffffff' : '#111111' const measurementColor = isDark ? '#ffffff' : '#111111'
@@ -508,13 +509,16 @@ export const WallTool: React.FC = () => {
} }
// Align the drafted point onto another object's nearest real anchor and // Align the drafted point onto another object's nearest real anchor and
// publish the guide. Alt bypasses alignment; Shift bypasses all guided // publish the guide. Alt bypasses alignment. Returns the possibly snapped
// snapping. Returns the possibly snapped point. // point.
const alignPoint = ( const alignPoint = (
point: WallPlanPoint, point: WallPlanPoint,
options: { applySnap?: boolean; bypass?: boolean }, options: { applySnap?: boolean; bypass?: boolean },
): WallPlanPoint => { ): WallPlanPoint => {
if (options.bypass || alignmentCandidates.length === 0) { // Figma alignment pulls the endpoint onto existing wall corners / edges,
// so it is a line snap — suppress it whenever magnetic snap is off
// (`'off'` / `'angles'`), matching the wall-geometry snap above.
if (options.bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return point return point
} }
@@ -546,19 +550,17 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls() const walls = getCurrentLevelWalls()
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Default path: grid + magnetic snap, with 15° angle lock while // Snapping is governed entirely by the snapping mode (grid / lines /
// drafting. Shift is a hard snap bypass: no grid, magnetic, angle, // angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
// or alignment snap. // Alt still bypasses Figma-style alignment guides independently.
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true const angleLocked = buildingState.current === 1 && isAngleSnapActive()
const angleLocked = buildingState.current === 1 && !bypassSnap const bypassAlign = event.nativeEvent?.altKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
const snapResult = snapWallDraftPointDetailed({ const snapResult = snapWallDraftPointDetailed({
point: localPoint, point: localPoint,
walls, walls,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked, angleSnap: angleLocked,
bypassSnap, magnetic: isMagneticSnapActive(),
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}) })
gridPosition = alignPoint(snapResult.point, { gridPosition = alignPoint(snapResult.point, {
applySnap: !angleLocked, applySnap: !angleLocked,
@@ -590,7 +592,6 @@ export const WallTool: React.FC = () => {
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if ( if (
!bypassSnap &&
previousWallEnd && previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1]) (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
) { ) {
@@ -633,16 +634,14 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls() const walls = getCurrentLevelWalls()
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true const bypassAlign = event.nativeEvent?.altKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
if (buildingState.current === 0) { if (buildingState.current === 0) {
const snappedStart = alignPoint( const snappedStart = alignPoint(
snapWallDraftPointDetailed({ snapWallDraftPointDetailed({
point: localClick, point: localClick,
walls, walls,
bypassSnap, magnetic: isMagneticSnapActive(),
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point, }).point,
{ bypass: bypassAlign }, { bypass: bypassAlign },
) )
@@ -665,15 +664,14 @@ export const WallTool: React.FC = () => {
// `onGridMove` writes a real BoxGeometry skips that frame. // `onGridMove` writes a real BoxGeometry skips that frame.
setDraftMeasurement(null) setDraftMeasurement(null)
} else if (buildingState.current === 1) { } else if (buildingState.current === 1) {
const angleLocked = !bypassSnap const angleLocked = isAngleSnapActive()
const snappedEnd = alignPoint( const snappedEnd = alignPoint(
snapWallDraftPointDetailed({ snapWallDraftPointDetailed({
point: localClick, point: localClick,
walls, walls,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked, angleSnap: angleLocked,
bypassSnap, magnetic: isMagneticSnapActive(),
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point, }).point,
{ {
applySnap: !angleLocked, applySnap: !angleLocked,
@@ -729,20 +727,6 @@ export const WallTool: React.FC = () => {
} }
} }
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
// angle lock isn't stuck off when focus returns.
const onBlur = () => {
shiftPressed.current = false
}
const onCancel = () => { const onCancel = () => {
if (buildingState.current === 1) { if (buildingState.current === 1) {
markToolCancelConsumed() markToolCancelConsumed()
@@ -753,17 +737,11 @@ export const WallTool: React.FC = () => {
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear() useWallSnapIndicator.getState().clear()
useSegmentDraftChain.getState().clear('wall') useSegmentDraftChain.getState().clear('wall')
+1
View File
@@ -209,6 +209,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
// Stage C: floor-plan polygon. ctx.parent gives the wall for direction // Stage C: floor-plan polygon. ctx.parent gives the wall for direction
// + thickness — same shape as door. // + thickness — same shape as door.
floorplan: buildWindowFloorplan, floorplan: buildWindowFloorplan,
floorplanDependsOnSiblings: true,
// Stage D — placement + move-on-wall. Same recipe as door. See // Stage D — placement + move-on-wall. Same recipe as door. See
// `nodes/src/window/{tool,move-tool,window-math}.ts`. // `nodes/src/window/{tool,move-tool,window-math}.ts`.
tool: () => import('./tool'), tool: () => import('./tool'),
@@ -1,16 +1,19 @@
import { import {
type AnyNodeId, type AnyNodeId,
clampDoorOperationState, clampDoorOperationState,
DEFAULT_WALL_THICKNESS,
type DoorNode, type DoorNode,
DoorNode as DoorNodeSchema, DoorNode as DoorNodeSchema,
getDoorRenderOpenAmount, getDoorRenderOpenAmount,
getEffectiveNode, getEffectiveNode,
getWallThickness,
type SceneMaterial, type SceneMaterial,
type SceneMaterialId, type SceneMaterialId,
sceneRegistry, sceneRegistry,
useInteractive, useInteractive,
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
@@ -26,6 +29,7 @@ import {
resolveMaterialRef, resolveMaterialRef,
} from '../../lib/materials' } from '../../lib/materials'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
// Invisible material for root mesh — used as selection hitbox only // Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -186,6 +190,19 @@ function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh {
return mesh return mesh
} }
const NO_RAYCAST = () => {}
// An open door leaf swings perpendicular to the wall, so in a top-down view its
// flat panel blankets the room interior and wins the selection raycast over the
// slab/items beneath it. Drop the swung leaf out of the raycast so a click on
// the floor falls through to what's underneath; the door stays selectable via
// its proud invisible cutout proxy at the opening (see syncDoorCutout).
function disableSubtreeRaycast(object: THREE.Object3D) {
object.traverse((child) => {
;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST
})
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean { function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots const slots = node.slots
if (!slots) return false if (!slots) return false
@@ -1323,6 +1340,14 @@ function addDoorLeaf(
) )
addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0)
} }
// When the leaf is swung open it projects into the room and would otherwise
// win a top-down selection click over the floor beneath it. Drop only the
// swung leaf out of the raycast; a closed leaf stays in the wall plane and
// keeps its hit-eligibility (so paint-by-slot still works on it).
if (Math.abs(swingRotation) > 1e-3) {
disableSubtreeRaycast(leafGroup)
}
} }
function addFoldingDoor( function addFoldingDoor(
@@ -2556,18 +2581,21 @@ function hideEmptyGeometryMeshes(root: THREE.Object3D) {
} }
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ── // ── Cutout: invisible raycast hit target for the whole opening ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) { if (!cutout) {
cutout = new THREE.Mesh() cutout = new THREE.Mesh()
cutout.name = 'cutout' cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it // The cutout (invisible) is proud of the wall on both faces, so it wins the
// wins the scene raycast over the wall in front of the recessed door body — // scene raycast over the wall in front of the recessed door body — making it
// making it the selection AND paint hit target for the whole opening. The // the selection AND paint hit target for the whole opening. The paint
// paint capability then re-raycasts the door's parts to find the slot. // capability then re-raycasts the door's parts to find the slot. Its depth
// is snug to the wall (not 1m) so it no longer blankets the room floor in a
// top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
mesh.add(cutout) mesh.add(cutout)
} }
cutout.geometry.dispose() cutout.geometry.dispose()
const depth = resolveOpeningCutoutProxyDepth(node)
const openingShape = getEffectiveOpeningShape(node) const openingShape = getEffectiveOpeningShape(node)
if (openingShape === 'arch') { if (openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry( cutout.geometry = new THREE.ExtrudeGeometry(
@@ -2579,12 +2607,12 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
getClampedArchHeight(node.width, node.height, node.archHeight), getClampedArchHeight(node.width, node.height, node.archHeight),
), ),
{ {
depth: 1, depth,
bevelEnabled: false, bevelEnabled: false,
curveSegments: 24, curveSegments: 24,
}, },
) )
cutout.geometry.translate(0, 0, -0.5) cutout.geometry.translate(0, 0, -depth / 2)
} else if (openingShape === 'rounded') { } else if (openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry( cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedTopShape( createRoundedTopShape(
@@ -2595,18 +2623,30 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
getDoorTopRadii(node, node.width, node.height), getDoorTopRadii(node, node.width, node.height),
), ),
{ {
depth: 1, depth,
bevelEnabled: false, bevelEnabled: false,
curveSegments: 24, curveSegments: 24,
}, },
) )
cutout.geometry.translate(0, 0, -0.5) cutout.geometry.translate(0, 0, -depth / 2)
} else { } else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
} }
cutout.visible = false cutout.visible = false
} }
// Resolve the cutout proxy depth from the opening's parent wall thickness so
// the proxy stays proud of both wall faces (front/back selection) without the
// old 1m depth that blanketed the floor. Falls back to the default thickness
// when the parent wall isn't a resolvable wall node.
function resolveOpeningCutoutProxyDepth(node: DoorNode): number {
const parentId = node.parentId
const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
const wallThickness =
parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS
return getOpeningCutoutProxyDepth(wallThickness)
}
/** /**
* Build a fresh door mesh for preview/ghost rendering. * Build a fresh door mesh for preview/ghost rendering.
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware). * Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
@@ -10,6 +10,20 @@ export type OpeningCutoutRect = {
top: number top: number
} }
// The cutout proxy doubles as the invisible raycast hit target for an opening:
// centered on the wall and extending past both faces so it wins the scene
// raycast over the recessed door/window body for front AND back selection +
// paint. It only needs to clear the wall thickness plus a small proud margin —
// the wall CSG brush ignores this proxy's depth entirely (it rebuilds its own
// full-thickness box from the proxy's X/Y bounds in `collectCutoutBrushes`), so
// a snug depth keeps the cut intact while no longer blanketing the room floor in
// a top-down view (the bug a 1m-deep proxy caused in narrow hallways).
const OPENING_CUTOUT_PROXY_PROUD_MARGIN = 0.08
export function getOpeningCutoutProxyDepth(wallThickness: number): number {
return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN
}
type CornerRadii = { type CornerRadii = {
topLeft: number topLeft: number
topRight: number topRight: number
@@ -1,12 +1,15 @@
import { import {
type AnyNodeId, type AnyNodeId,
DEFAULT_WALL_THICKNESS,
getEffectiveNode, getEffectiveNode,
getWallThickness,
type SceneMaterial, type SceneMaterial,
type SceneMaterialId, type SceneMaterialId,
sceneRegistry, sceneRegistry,
useInteractive, useInteractive,
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
type WallNode,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
@@ -22,6 +25,7 @@ import {
resolveMaterialRef, resolveMaterialRef,
} from '../../lib/materials' } from '../../lib/materials'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
// Invisible material for root mesh — used as selection hitbox only // Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -163,6 +167,21 @@ function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh {
return mesh return mesh
} }
const NO_RAYCAST = () => {}
// An open casement sash swings perpendicular to the wall, so in a top-down view
// its flat panel blankets the room interior and wins the selection raycast over
// the slab/items beneath it. Drop the swung sash out of the raycast so a floor
// click falls through; the window stays selectable via its proud invisible
// cutout proxy at the opening (see syncWindowCutout). Skipped while closed so
// paint-by-slot still resolves on the sash.
function disableSubtreeRaycastIfSwung(object: THREE.Object3D, rotationY: number) {
if (Math.abs(rotationY) <= 1e-3) return
object.traverse((child) => {
;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST
})
}
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean { function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
const slots = node.slots const slots = node.slots
if (!slots) return false if (!slots) return false
@@ -1020,6 +1039,8 @@ function addRectCasementSash(
) )
currentWindowSlot = 'glass' currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
disableSubtreeRaycastIfSwung(sash, rotationY)
} }
function addFrenchCasementHingeMarkers( function addFrenchCasementHingeMarkers(
@@ -1244,6 +1265,7 @@ function addShapedFrenchCasementSash(
sashDepth * 0.08, sashDepth * 0.08,
) )
} }
disableSubtreeRaycastIfSwung(sash, rotationY)
return return
} }
@@ -1271,6 +1293,7 @@ function addShapedFrenchCasementSash(
sashDepth * 0.08, sashDepth * 0.08,
) )
} }
disableSubtreeRaycastIfSwung(sash, rotationY)
} }
function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
@@ -1534,6 +1557,8 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
} }
disableSubtreeRaycastIfSwung(sash, sash.rotation.y)
currentWindowSlot = 'frame' currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
@@ -1696,6 +1721,8 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
currentWindowSlot = 'glass' currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
disableSubtreeRaycastIfSwung(sash, sash.rotation.y)
// Small hinge markers make the pivot side legible when the sash is closed. // Small hinge markers make the pivot side legible when the sash is closed.
currentWindowSlot = 'frame' currentWindowSlot = 'frame'
addBox( addBox(
@@ -3597,20 +3624,23 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
} }
function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ── // ── Cutout: invisible raycast hit target for the whole opening ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) { if (!cutout) {
cutout = new THREE.Mesh() cutout = new THREE.Mesh()
cutout.name = 'cutout' cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it // The cutout (invisible) is proud of the wall on both faces, so it wins the
// wins the scene raycast over the wall in front of the recessed window — // scene raycast over the wall in front of the recessed window — making it
// making it the selection AND paint hit target for the whole opening. The // the selection AND paint hit target for the whole opening. The paint
// paint capability then re-raycasts the window's parts to find the slot. // capability then re-raycasts the window's parts to find the slot. Its depth
// is snug to the wall (not 1m) so it no longer blankets the room floor in a
// top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
mesh.add(cutout) mesh.add(cutout)
} }
cutout.geometry.dispose() cutout.geometry.dispose()
const depth = resolveOpeningCutoutProxyDepth(node)
if (isRectangleOnlyWindowType(node)) { if (isRectangleOnlyWindowType(node)) {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
} else if (node.openingShape === 'arch') { } else if (node.openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry( cutout.geometry = new THREE.ExtrudeGeometry(
createArchShape( createArchShape(
@@ -3621,12 +3651,12 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
getClampedArchHeight(node.width, node.height, node.archHeight), getClampedArchHeight(node.width, node.height, node.archHeight),
), ),
{ {
depth: 1, depth,
bevelEnabled: false, bevelEnabled: false,
curveSegments: 24, curveSegments: 24,
}, },
) )
cutout.geometry.translate(0, 0, -0.5) cutout.geometry.translate(0, 0, -depth / 2)
} else if (node.openingShape === 'rounded') { } else if (node.openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry( cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedShape( createRoundedShape(
@@ -3637,18 +3667,30 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
getWindowRoundedRadii(node, node.width, node.height), getWindowRoundedRadii(node, node.width, node.height),
), ),
{ {
depth: 1, depth,
bevelEnabled: false, bevelEnabled: false,
curveSegments: 24, curveSegments: 24,
}, },
) )
cutout.geometry.translate(0, 0, -0.5) cutout.geometry.translate(0, 0, -depth / 2)
} else { } else {
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
} }
cutout.visible = false cutout.visible = false
} }
// Resolve the cutout proxy depth from the opening's parent wall thickness so
// the proxy stays proud of both wall faces (front/back selection) without the
// old 1m depth that blanketed the floor. Falls back to the default thickness
// when the parent wall isn't a resolvable wall node.
function resolveOpeningCutoutProxyDepth(node: WindowNode): number {
const parentId = node.parentId
const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
const wallThickness =
parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS
return getOpeningCutoutProxyDepth(wallThickness)
}
/** /**
* Build a fresh window mesh for preview/ghost rendering. * Build a fresh window mesh for preview/ghost rendering.
* Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware). * Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware).
+2
View File
@@ -14,6 +14,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
| [item-authoring](item-authoring.md) | Content-author contract for catalog item GLBs: `slot_` material naming, authored defaults + `pascal_material` extras, the `cutout` reserved mesh, UV world scale, and the validated Blender/export recipe | | [item-authoring](item-authoring.md) | Content-author contract for catalog item GLBs: `slot_` material naming, authored defaults + `pascal_material` extras, the `cutout` reserved mesh, UV world scale, and the validated Blender/export recipe |
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 | | [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
| [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults | | [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults |
| [interaction-scope](interaction-scope.md) | The authoritative interaction state machine ("the spine"): `InteractionScope` union, the begin/update/end/endIf contract, the raycast hot-set, and the overlay scope matrix |
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
@@ -25,4 +26,5 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
## Reading order for an architecture review ## Reading order for an architecture review
1. [layers](layers.md), [systems](systems.md), [renderers](renderers.md), [tools](tools.md), [viewer-isolation](viewer-isolation.md) — required every review. 1. [layers](layers.md), [systems](systems.md), [renderers](renderers.md), [tools](tools.md), [viewer-isolation](viewer-isolation.md) — required every review.
- When the diff touches placement / move / handle / reshape / box-select / paint or any overlay or picking behaviour, also read [interaction-scope](interaction-scope.md).
2. The remaining pages on demand, based on what the diff touches. 2. The remaining pages on demand, based on what the diff touches.
+142
View File
@@ -0,0 +1,142 @@
# Interaction Scope
*The authoritative interaction state machine ("the spine") — one scope describes "what the user is currently doing".*
Applies to: `packages/editor/src/lib/interaction/**`, `packages/editor/src/store/use-interaction-scope.ts`.
Before this, "what is the user doing right now?" was re-derived from 7+ independent
`useEditor` flags (`movingNode`, `placementDragMode`, `activeHandleDrag`,
`curvingWall`, `curvingFence`, `editingHole`, `movingWallEndpoint`,
`movingFenceEndpoint`). Every overlay and pick site re-derived its behaviour from
a different subset, so the flags could drift into illegal combinations (moving +
curving at once; a stale `movingNode` after a drag ended). The scope collapses
them into one discriminated union, making those combinations unrepresentable: a
scope is exactly one interaction at a time, and `idle` carries no payload.
---
## The model
`InteractionScope` (`lib/interaction/scope.ts`) is a discriminated union on `kind`:
| `kind` | Payload | What |
|---|---|---|
| `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. |
| `placing` | `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. |
| `moving` | `nodeId`, `nodeType`, `view` | Moving an existing node. |
| `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. |
| `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). |
| `reshaping` | `nodeId`, `reshape`, `holeIndex?` | Reshaping a selected node's geometry. `reshape` is `curve \| hole \| endpoint \| boundary`. |
| `box-select` | — | Marquee selection drag. |
| `painting` | — | Material paint application. |
`reshaping` groups endpoint/curve/hole/boundary edits as sub-states of one scope
(rather than four sibling kinds) — there is one node and one in-flight reshape,
so "curving and hole-editing at once" stays unrepresentable. `view` is `'2d' | '3d'`.
### Helpers
- `isIdle(scope)` / `isActive(scope)``idle` vs anything else (`ActiveInteractionScope`).
- `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node.
- `selectionEnabled(scope)` — true only while `idle`. During any active interaction the pointer belongs to that interaction's body, not to selecting a different object; the picking choke point must not route a hover/click to selection while this is false.
---
## The store contract
`useInteractionScope` (default export of `store/use-interaction-scope.ts`) is the
single owner. Exactly one scope at a time; the only writable shape is
`InteractionScope`, so there is no setter that can leave a half-state.
| Method | Behaviour |
|---|---|
| `begin(scope: ActiveInteractionScope)` | Enter an interaction. If one is already active it is replaced (single owner, no producer races). |
| `update(patch)` | Patch the current scope's payload. **Ignored when idle, and ignored when the patch's `kind` differs from the active kind** — payload updates must not change which interaction is running (use `begin` for that). |
| `end()` | Return to idle atomically. Both commit and cancel call it; the write-vs-revert distinction lives in the interaction body, not here. |
| `endIf(match)` | Return to idle only if the active scope satisfies `match`. |
**Atomic-end invariant.** `end()` sets the scope back to `IDLE_SCOPE` in one
write — no interaction payload can leak past the end of its interaction (no stale
`nodeId`, no half-cleared flags). `endIf` exists because scope is currently
driven from independent legacy flag clears (below): clearing one flag (e.g. a
fence curve) must not stomp an unrelated active scope (e.g. a wall move), so the
clear only ends the scope if it owns it.
---
## Hot-set: what is raycast-eligible during an interaction
`lib/interaction/hot-set.ts` answers "which scene objects can the active
interaction target?" It is never hand-authored per interaction — it falls out of
the node's `asset.attachTo` plus whether a candidate exposes a top surface.
`attachClassOf(attachTo)` collapses attachment to three `AttachClass` values:
- `wall``attachTo` of `wall` or `wall-side`.
- `ceiling``attachTo` of `ceiling`.
- `surface` — everything else ("floor item" really means *surface-resting*: rests on the floor **or** any host's top surface).
`isPickableForAttach(placed, candidate)` decides, for a node of attach class
`placed`, whether a `HotSetCandidate` is a valid host/surface:
- `wall` → only `wall` candidates.
- `ceiling` → only `ceiling` candidates.
- `surface` → the floor (`isFloorLike`), or any candidate that `exposesTop` (registry `capabilities.surfaces.top`) — but **never** a ceiling-mounted host. A floor lamp must not land on a ceiling fan; a ceiling fan's `attachClass` is `ceiling` and is excluded as a host top (Track E).
`isCandidateInHotSet(scope, placedAttachClass, candidate)` lifts this to a whole scope:
- `idle``true` (selection/phase filtering stays in the selection manager; the hot-set only narrows what an *active* interaction can target).
- `placing` / `moving``isPickableForAttach`, or `true` when `placedAttachClass` is `null`.
- every other active scope → `false`: nothing in the scene is a placement target, so the interaction body's own raycast owns the pointer.
`HotSetCandidate` (`type`, `isFloorLike`, `exposesTop`, `attachClass`) is derived
from the candidate node + its registry definition by the caller, keeping this
module pure and unit-testable without the scene or registry.
---
## Overlay policy: the scope matrix
`resolveOverlayPolicy(scope)` (`lib/interaction/overlay-policy.ts`) returns the
"Sims-light" overlay behaviour: default-off, opt-in for the active action. During
any non-idle scope, scene objects stay visible but non-pickable, and DOM/HUD
overlays step back differentiated by how distracting they are.
| Overlay | Idle | Any active scope |
|---|---|---|
| Zone labels | shown | hidden (not a primary editing concern) |
| Context badges (hover name pills) | shown | faded + `pointer-events: none` |
| Conflicting controls (other objects' handles, floating action menu) | shown | hidden |
| Scene objects pickable | yes | no (the hot-set owns targeting; context preserved, can't grab the wrong thing) |
| Active affordances (ghost, snap guides, dimension labels, the active handle) | shown | shown |
| Contextual control HUD interactive | yes | yes (it *is* the active interaction's own controls — exempt from the pointer-events step-back) |
The policy is binary (`IDLE_POLICY` vs `ACTIVE_POLICY`) keyed on `isActive`.
---
## Migration status (strangler fig)
The scope is the target source of truth, but the legacy `useEditor` flags still
exist as a mirror and are being retired reader-by-reader. Today the scope is
**driven from** the central `useEditor` setters — `setMovingNode`,
`setActiveHandleDrag`, `setCurvingWall`/`setCurvingFence`, `setEditingHole`,
`setMovingWallEndpoint`/`setMovingFenceEndpoint`, `setMode` (for painting) — and
from the box-select tool. Each setter calls `begin`/`end` (and `endIf`, so an
independent flag clear can't stomp an unrelated scope) to keep the scope in sync.
**Contributors:**
- Add a new interaction by calling `begin(...)` / `end()` on `useInteractionScope`, **not** by adding a new `useEditor` flag.
- Read "what the user is doing" through the scope and its helpers (`isActive`, `scopeNodeId`, `selectionEnabled`), not by recombining flags. New readers should consume the scope so the legacy flag can be deleted once it has no readers.
- Add a new attach behaviour by setting `attachTo` on the asset — the hot-set follows with zero per-kind wiring.
---
## Rules
- **One owner, one scope.** Only `useInteractionScope` writes the scope, and only via `begin`/`update`/`end`/`endIf`. Never reconstruct interaction state from a private combination of flags.
- **`end` is atomic and payload-free.** Never leave a `nodeId`/payload behind on idle; commit-vs-revert logic belongs in the interaction body before `end`.
- **`update` cannot change `kind`.** Switching interactions is a `begin`, not a patch.
- **Hot-set and overlay policy are pure derivations of the scope** (and, for the hot-set, the candidate metadata). Don't branch overlay/picking behaviour on legacy flags — branch on the scope.
- **Don't add new `useEditor` interaction flags.** New interactions go through the scope.
+6
View File
@@ -36,6 +36,12 @@ return <mesh ref={ref} {...events} />
Events are suppressed during camera drag (`useViewer.getState().cameraDragging`). Events are suppressed during camera drag (`useViewer.getState().cameraDragging`).
Selection/hover picking is only meaningful while the interaction scope is `idle`
(`selectionEnabled(scope)`). During an active placement/move/etc., the pointer
belongs to that interaction's body and the hot-set narrows which scene objects
are raycast-eligible — see [interaction-scope](interaction-scope.md) for the
hot-set derivation and the overlay scope matrix.
--- ---
## Viewer Selection Manager ## Viewer Selection Manager
+2
View File
@@ -12,6 +12,8 @@ Tools are React components that capture user input (pointer, keyboard) and trans
See `apps/editor/components/tools/tool-manager.tsx`. See `apps/editor/components/tools/tool-manager.tsx`.
> **What the user is doing right now** is owned by the interaction state machine, not by tool-local flags. A tool that starts a placement / move / handle / reshape / box-select / paint interaction enters it through `useInteractionScope.begin(...)` and leaves through `end()` — see [interaction-scope](interaction-scope.md). Do not add a new `useEditor` flag for a new interaction.
## Tool Categories by Phase ## Tool Categories by Phase
**Site** **Site**