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 { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
Tooltip,
TooltipContent,
@@ -152,15 +152,19 @@ function activateRoofFeatureTool(kind: string): void {
* with the kind's own `def.defaults()`. The "Painting" type swaps in the
* 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() {
const activeTool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow)
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
// tile — keep the segment tile lit so the panel (and the way back) stays
@@ -200,8 +204,23 @@ export function BuildTab() {
return features
}, [])
const isTypeActive = (type: BuildType) =>
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id
// Tile highlight derives from the single source of truth (the active tool /
// 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) => {
if (type.mode === 'material-paint') {
@@ -213,15 +232,18 @@ export function BuildTab() {
} else if (type.kind) {
activateBuildTool(type.kind)
}
setSelectedTypeId(type.id)
}, [])
// 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)
useEffect(() => {
if (didInitRef.current) return
didInitRef.current = true
const ed = useEditor.getState()
if (ed.mode === 'build' && ed.tool) return
const firstType = BUILD_TYPES.find((t) => t.kind)
if (firstType) handleTypeClick(firstType)
}, [handleTypeClick])
@@ -274,7 +296,9 @@ export function BuildTab() {
<div className="min-h-0 flex-1 overflow-y-auto">
<MaterialPaintPanel />
</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="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
<TooltipProvider delayDuration={0} disableHoverableContent>
@@ -319,7 +343,7 @@ export function BuildTab() {
</div>
</TooltipProvider>
</div>
) : selectedTypeId === 'mep' ? (
) : isMepActive ? (
<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>
<TooltipProvider delayDuration={0} disableHoverableContent>
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode } from '../schema'
import { planAutoCeilingsForLevel } from './space-detection'
import { planAutoCeilingsForLevel, planAutoSlabsForLevel } from './space-detection'
const square: Array<[number, number]> = [
[0, 0],
@@ -90,3 +90,26 @@ describe('planAutoCeilingsForLevel', () => {
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('||')
}
function slabGeometrySignature(slab: SlabNodeType) {
const polygon = slab.polygon
.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`)
.join(';')
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('||')
}
// Trigger signature is wall-only on purpose: re-detection should fire on a
// genuine remodel (wall geometry change), never when an auto-slab is edited or
// deleted. Hashing slabs here created a feedback loop where deleting an
// auto-slab re-fired detection and recreated it.
function levelStructureSnapshots(nodes: Record<string, any>) {
const byLevel = new Map<string, { walls: WallNode[]; slabs: SlabNodeType[] }>()
const getEntry = (levelId: string) => {
const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] }
byLevel.set(levelId, entry)
return entry
}
const byLevel = new Map<string, WallNode[]>()
for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
if ((node as any).type === 'wall') {
getEntry((node as any).parentId).walls.push(node as WallNode)
} else if ((node as any).type === 'slab') {
getEntry((node as any).parentId).slabs.push(SlabNode.parse(node))
}
if ((node as any).type !== 'wall') continue
const levelId = (node as any).parentId as string
const walls = byLevel.get(levelId) ?? []
walls.push(node as WallNode)
byLevel.set(levelId, walls)
}
const snapshots = new Map<string, string>()
for (const [levelId, entry] of byLevel.entries()) {
snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`)
for (const [levelId, walls] of byLevel.entries()) {
snapshots.set(levelId, levelWallSnapshot(walls))
}
return snapshots
@@ -692,13 +674,15 @@ export function planAutoSlabsForLevel(
const matchedDetectedIdx = new Set<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) {
autoBySignature.set(entry.sig, entry)
const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
}
detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig)
const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return
matchedDetectedIdx.add(index)
@@ -875,13 +859,15 @@ export function planAutoCeilingsForLevel(
const matchedDetectedIdx = new Set<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) {
autoBySignature.set(entry.sig, entry)
const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
}
detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig)
const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return
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
// without importing `useScene`. Builders stay pure and unit-testable.
//
// Future extension: `levelData?: { miters?: ... }` for level-scoped batch
// data (wall mitering across an entire level). Decided alongside the wall
// migration off its dedicated system (Phase 3+).
// `levelData` carries level-scoped batch data (wall mitering across an
// entire level) from registry dispatchers into pure builders.
export type GeometryContext = {
/** Look up any node by ID. Returns undefined if the node doesn't exist. */
@@ -30,18 +29,16 @@ export type GeometryContext = {
parent: AnyNode | null
/**
* Pre-computed level-batch data, populated by the dispatcher when the
* kind declares `def.computeLevelData`. Shared across every
* `def.geometry(node, ctx)` call in the same level batch within a
* single frame, so kinds whose geometry depends on cross-sibling
* data (wall mitering, gradient sky uniforms across a zone, etc.)
* don't pay an O(N²) recomputation cost.
* kind declares `def.computeLevelData` (3D) or
* `def.computeFloorplanLevelData` (2D). Shared across every builder call
* in the same level batch within a single frame/render pass, so kinds
* whose geometry depends on cross-sibling data (wall mitering, gradient
* 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
* own `LevelData` shape inside `def.geometry` (the same kind owns
* both the `computeLevelData` return shape and the `geometry`
* consumer, so the cast is internal). Only populated for `def.
* geometry` calls today; not used by `def.floorplan` (which already
* has cheap access to siblings through `ctx.siblings`).
* own `LevelData` shape inside `def.geometry` / `def.floorplan` (the
* same kind owns both the compute hook's return shape and the builder
* consumer, so the cast is internal).
*/
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.
*/
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
* 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.
*/
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
* 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 {
canAttach,
canHostOnTop,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,
@@ -14,6 +15,16 @@ import {
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(
kind: string,
capabilities: Capabilities = {},
@@ -233,4 +244,30 @@ describe('pickHost', () => {
})
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
}
/**
* 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
* 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 hostable = def?.capabilities.hostable
if (!hostable) continue
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) {
// capability declares specific parents; verify the placed kind's own def
// also permits this host kind.
}
if (!canHostOnTop(host)) continue
if (args.hitTest && !args.hitTest(host, args.point)) continue
return host
}
+1
View File
@@ -34,6 +34,7 @@ export {
type AttachError,
type AttachResult,
canAttach,
canHostOnTop,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,
@@ -48,6 +48,7 @@ export function FloorplanRegistryActionMenu() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const movingNode = useEditor((s) => s.movingNode)
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
// FloatingActionMenu in split view — that menu hides while the floorplan
// is hovered, so this one must only show then. Mirrors the legacy
@@ -141,6 +142,11 @@ export function FloorplanRegistryActionMenu() {
const handleMove = () => {
sfxEmitter.emit('sfx:item-pick')
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
// selection-gated affordances unmount during the drag. Specifically
// the slab / ceiling boundary editor (`ToolManager` shows it when
@@ -12,6 +12,8 @@ import {
type GeometryContext,
isRegistryMovable,
kindsWithFloorplanScope,
type LiveNodeOverrides,
type LiveTransform,
nodeRegistry,
pauseSceneHistory,
resolveBuildingForLevel,
@@ -122,6 +124,46 @@ type RotationOverlayState = {
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 {
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
// 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 }))
}
// 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() {
const selectedLevelId = useViewer((s) => s.selection.levelId)
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
@@ -191,6 +243,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const renderCtx = useFloorplanRender()
const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Door / window placement (both build and move) needs the SVG's
// background click handler to run — it finds the closest wall via
// `findClosestWallPoint` and emits `wall:click` for the door / window
@@ -215,17 +268,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
structureLayer !== 'zones' &&
!movingNode &&
!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
// whenever a 3D mover publishes a per-frame position (see
// `usePlacementCoordinator`). Without this the 2D floor plan only
// 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`
// tracks the current / fallback level + cab travel, `useLiveNode
// Overrides` carries live-edit overrides from the inspector. Builders
// read both via `getState()` inside `def.floorplan`; subscribing here
// 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 selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
@@ -239,6 +303,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
const [activeDragId, setActiveDragId] = useState<string | 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(
(id: AnyNodeId, shouldToggle: boolean) => {
@@ -467,46 +534,128 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// tree the builder returns. Builders don't need to know about the
// partition.
const entries = useMemo(() => {
// Some builders read elevator runtime state imperatively; this keeps the memo subscribed.
void interactiveElevators
const previousCache = geometryCacheRef.current
const nextCache = new Map<string, CacheEntry>()
if (!levelId) {
geometryCacheRef.current = nextCache
return []
}
if (!levelId) return []
const out: {
id: AnyNodeId
node: AnyNode
base: FloorplanGeometry | null
overlay: FloorplanGeometry | null
selected: boolean
highlighted: boolean
}[] = []
// The sibling epoch bumps whenever a sibling-affecting node's LIVE state
// changes (a wall/door/window/gutter being dragged or live-edited). Only
// flagged kinds feed it, so dragging or rotating a plain item — which also
// publishes to liveTransforms / liveOverrides — leaves it stable and the
// hundreds of wall/door geometries stay cached. Committed structural edits
// are covered separately by keying flagged kinds on the `nodes` ref.
const siblingEpochInputs: unknown[] = []
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 visit = (id: AnyNodeId) => {
const collectLevelDataKind = (id: AnyNodeId) => {
const node = nodes[id]
if (!node) return
if ((node as { visible?: boolean }).visible === false) 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) {
if (!builder) return
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') {
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
@@ -517,67 +666,91 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// 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,
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
} else if (floorPlaced && hasPosition) {
effectiveNode = applyPositionLiveTransform(node, live)
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
}
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) {
const surface = node as {
if (dx === 0 && dz === 0) return sourceNode
const surface = sourceNode as {
polygon: Array<[number, number]>
holes?: Array<Array<[number, number]>>
}
effectiveNode = {
...node,
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
}
}
// 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
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, {
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,
)
if (geometry) {
const { base, overlay } = splitFloorplanOverlay(geometry)
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 node = nodes[id]
if (!node) return
if ((node as { visible?: boolean }).visible === false) return
buildEntry(id, node)
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
if (Array.isArray(childIds)) {
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
if (parentId !== activeBuildingId) continue
const cid = id as AnyNodeId
const def = nodeRegistry.get(node.type)
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,
buildEntry(cid, node, {
children: [],
siblings: [],
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
// priority.
out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
geometryCacheRef.current = nextCache
return out
}, [
levelId,
@@ -991,6 +1126,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick')
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}
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
@@ -1947,6 +2089,7 @@ function buildContext(
moving: boolean
palette: FloorplanPalette | undefined
},
levelData?: unknown,
): GeometryContext {
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
@@ -1979,6 +2122,7 @@ function buildContext(
children,
siblings,
parent,
levelData,
viewState: viewState.palette
? {
selected: viewState.selected,
@@ -2071,6 +2215,37 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
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 =
* 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 { useCallback, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import useEditor from '../../store/use-editor'
import useInteractionScope from '../../store/use-interaction-scope'
import { formatMeasurement, MeasurementPill } from './measurement-pill'
import { NodeActionMenu } from './node-action-menu'
@@ -137,6 +139,11 @@ function getAttributeVersion(
: 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 {
const parts: string[] = []
object.traverse((child) => {
@@ -218,6 +225,10 @@ export function FloatingActionMenu() {
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
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 menuScaleRef = useRef<HTMLDivElement>(null)
@@ -329,23 +340,44 @@ export function FloatingActionMenu() {
// mid-resize). A spinning child changes the head's matrix, not the
// registered group's, so it never triggers a recompute → the menu
// 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 dragActive = activeHandleDrag?.nodeId === selectedId
const effectiveNode = getEffectiveNode(node)
const geometryKey = getObjectGeometryKey(obj)
const selectionChanged =
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
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)) {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
_anchorBox.setFromObject(obj)
if (!_anchorBox.isEmpty()) {
_anchorBox.getCenter(_anchorCenter)
// Position above the object. Per-type offsets clear each kind's
// 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
}
} else {
@@ -624,7 +656,8 @@ export function FloatingActionMenu() {
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint ||
movingFenceEndpoint ||
curvingFence
curvingFence ||
menuStepBack
)
return null
@@ -75,9 +75,11 @@ import {
buildFloorplanItemEntry,
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
collectLevelDescendants,
floorplanLocalToWorldPoint,
getFloorplanWall as getSharedFloorplanWall,
rotatePlanVector as rotateSharedPlanVector,
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
worldToFloorplanLocalPoint,
} from '../../lib/floorplan'
import { guideEmitter } from '../../lib/guide-events'
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
@@ -87,7 +89,11 @@ import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import { cn } from '../../lib/utils'
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
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 { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
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)
}
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(
svgPoint: SvgPoint,
viewBox: { minX: number; minY: number; width: number; height: number },
@@ -7678,7 +7651,7 @@ export function FloorplanPanel({
walls,
ignoreWallIds: [dragState.wallId],
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
})
const snappedPoint = snapResult.point
// Magnetic beacon at the endpoint when it locked onto existing geometry.
@@ -8537,30 +8510,30 @@ export function FloorplanPanel({
}
if (isFenceBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
// Figma alignment — same endpoint-wins precedence as the wall branch.
// While a draft is open the segment locks to 15° rays from its start
// unless Shift is held; Shift bypasses grid, magnetic, angle, and
// alignment snap.
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
// While a draft is open the segment locks to 15° rays from its start.
// Snapping is governed by the snapping mode (`'off'` is the bypass);
// there is no Shift hold-to-bypass. Alt still bypasses Figma alignment.
const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
const fenceSnapped = snapFenceDraftPoint({
point: planPoint,
walls,
fences,
start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap,
bypassSnap,
magnetic: isMagneticSnapActive(),
})
const fenceGridBase = bypassSnap ? planPoint : snapWallPointToGrid(planPoint)
const fenceGridBase = snapWallPointToGrid(planPoint)
const fenceLocked =
!bypassSnap &&
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
let snappedPoint = fenceSnapped
if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear()
else
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)
@@ -8738,18 +8711,16 @@ export function FloorplanPanel({
}
// Wall draft: grid + magnetic snap, then Figma-style alignment.
// While a draft is open the segment locks to 15° rays from its
// start unless Shift is held. Shift bypasses grid, magnetic, angle,
// and alignment snap.
const bypassSnap = shiftPressed || event.shiftKey
const wallAngleSnap = draftStart !== null && !bypassSnap
// While a draft is open the segment locks to 15° rays from its start.
// Snapping is governed by the snapping mode (`'off'` is the bypass);
// there is no Shift hold-to-bypass. Alt still bypasses Figma alignment.
const wallAngleSnap = draftStart !== null && isAngleSnapActive()
const wallSnap = snapWallDraftPointDetailed({
point: planPoint,
walls,
start: draftStart ?? undefined,
angleSnap: wallAngleSnap,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: isMagneticSnapActive(),
})
const wallSnapped = wallSnap.point
// Locked onto existing geometry (corner / midpoint / crossing / edge) →
@@ -8761,7 +8732,9 @@ export function FloorplanPanel({
} else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
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
@@ -8780,8 +8753,9 @@ export function FloorplanPanel({
setDraftEnd((previousEnd) => {
if (
!bypassSnap &&
(!previousEnd || previousEnd[0] !== snappedPoint[0] || previousEnd[1] !== snappedPoint[1])
!previousEnd ||
previousEnd[0] !== snappedPoint[0] ||
previousEnd[1] !== snappedPoint[1]
) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -9044,7 +9018,7 @@ export function FloorplanPanel({
angleSnap?: boolean
bypassSnap?: boolean
step?: number
}) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }),
}) => snapWallDraftPoint({ ...args, magnetic: isMagneticSnapActive() }),
[],
)
const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
@@ -269,15 +269,27 @@ export function createArrowHitAreaGeometry() {
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() {
const geometry = new CylinderGeometry(
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
HIT_AREA_THICKNESS,
32,
)
geometry.computeBoundingSphere()
return geometry
const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2
const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth)
const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength)
const merged = mergeGeometries([armX, armZ], false)
if (!merged) {
armZ.dispose()
armX.computeBoundingSphere()
return armX
}
armX.dispose()
armZ.dispose()
merged.computeBoundingSphere()
return merged
}
export function createRotateArrowHitAreaGeometry() {
@@ -43,6 +43,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import { ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
@@ -177,7 +178,6 @@ export function NodeArrowHandles() {
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const placementDragMode = useEditor((state) => state.placementDragMode)
// Endpoint / curve drags reshape the selected wall or fence; hide its
// 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
@@ -203,9 +203,6 @@ export function NodeArrowHandles() {
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
[rawNode, liveOverride],
)
const isOwnPressDragMove =
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
const def = node ? nodeRegistry.get(node.type) : null
const descriptors = useMemo(() => {
if (!(node && def?.handles)) return null
@@ -218,7 +215,11 @@ export function NodeArrowHandles() {
Boolean(node && descriptors?.length) &&
!isFloorplanHovered &&
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 &&
!movingFenceEndpoint &&
!curvingWall &&
@@ -398,8 +399,15 @@ function NodeArrowHandlesForNode({
// resize that re-centres the mesh) must NOT fire for the non-active arrows
// here, or they'd lag behind the moving item.
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) => {
if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
return (
<ArrowHandle
activeIndex={activeIndex}
descriptor={descriptor}
@@ -413,7 +421,8 @@ function NodeArrowHandlesForNode({
rideObject={arrowFrame}
suppressFreeze={activeIsTranslate}
/>
))
)
})
return createPortal(
<group ref={outerRef}>
@@ -1135,8 +1144,21 @@ function ArcArrow({
}
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 {
onEnd: () => setRotationDelta(null),
onEnd: () => {
setRotationDelta(null)
if (isRotateShape) useEditor.getState().setActiveHandleDrag(null)
},
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
const hit = new Vector3()
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 { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import usePlacementPreview from '../../store/use-placement-preview'
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
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 = {
activePolygonDraftPoints: WallPlanPoint[]
@@ -212,8 +213,8 @@ export function useFloorplanBackgroundPlacement({
// start unless Shift is held; Shift bypasses grid, magnetic,
// angle, and alignment snap. `gridSnap` keeps the regular snap
// on the world XZ grid even when the building is rotated.
const fenceStep = WALL_GRID_STEP
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
const fenceStep = getSegmentGridStep()
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap && isAngleSnapActive()
const fenceSnapped = snapFenceDraftPoint({
point: planPoint,
walls,
@@ -221,6 +222,7 @@ export function useFloorplanBackgroundPlacement({
start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap,
bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
gridSnap: (p) => worldGridSnap(p, fenceStep),
})
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep)
@@ -230,7 +232,9 @@ export function useFloorplanBackgroundPlacement({
const snappedPoint =
fenceLocked || fenceAngleSnap
? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap })
: alignFloorplanDraftPoint(fenceSnapped, {
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(),
})
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
@@ -321,8 +325,8 @@ export function useFloorplanBackgroundPlacement({
// start unless Shift is held; Shift bypasses grid, magnetic,
// angle, and alignment snap. `gridSnap` keeps the regular snap
// on the world XZ grid even when the building is rotated.
const wallStep = WALL_GRID_STEP
const wallAngleSnap = draftStart !== null && !bypassSnap
const wallStep = getSegmentGridStep()
const wallAngleSnap = draftStart !== null && !bypassSnap && isAngleSnapActive()
const wallSnapped = snapWallDraftPoint({
point: planPoint,
walls,
@@ -340,7 +344,10 @@ export function useFloorplanBackgroundPlacement({
} else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
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 { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
// ─── Per-zone label editor ────────────────────────────────────────────────────
@@ -19,6 +21,10 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
const selectedZoneId = useViewer((s) => s.selection.zoneId)
const hoveredId = useViewer((s) => s.hoveredId)
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 isDeleteHovered = mode === 'delete' && hoveredId === zoneId
const [editing, setEditing] = useState(false)
@@ -149,7 +155,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
fontSize: 14,
fontFamily: 'sans-serif',
userSelect: 'none',
pointerEvents: 'auto',
pointerEvents: labelStepBack ? 'none' : 'auto',
opacity: labelStepBack ? 0.4 : undefined,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
@@ -3,7 +3,9 @@ import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { type Group, MathUtils, type Mesh } from 'three'
import type { MeshBasicNodeMaterial } from 'three/webgpu'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
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.
// 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.
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 zones = sceneRegistry.byType.zone || new Set()
const nodes = useScene.getState().nodes
@@ -84,7 +91,8 @@ export const ZoneSystem = () => {
// Labels: visible on the current level (regardless of mode), but never
// during snapshot capture.
const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel
const showLabel =
!isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
const labelOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== labelOpacity) {
@@ -1,9 +1,16 @@
import { type AssetInput, isObject } from '@pascal-app/core'
import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
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 {
return useEditor.getState().gridSnapStep
const state = useEditor.getState()
return resolveSnapFlags(state.snappingMode).grid ? state.gridSnapStep : 0
}
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.
*/
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
if (step <= 0) return position
const halfDim = dimension / 2
const offset = positiveModulo(halfDim, step)
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).
*/
export function snapToHalf(value: number, step = getGridSnapStep()): number {
if (step <= 0) return value
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`.
*/
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
if (step <= 0) return value
return Math.max(step, Math.ceil(value / step) * step)
}
@@ -16,6 +16,7 @@ import type {
WallNode,
} from '@pascal-app/core'
import {
canHostOnTop,
clampRectToRoofWallFace,
getRoofSegmentWallFace,
getScaledDimensions,
@@ -64,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
}
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
if (!canHostOnTop(surfaceItem)) return null
if (isLowProfileItemSurface(surfaceItem)) 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
// for storage. Without this, a rotated building drags placement off
// the world grid.
const bypassSnap = event.nativeEvent?.shiftKey === true
const bypassSnap = event.nativeEvent?.altKey === true
const [x, z] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
@@ -202,7 +204,7 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal)
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 y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
@@ -266,7 +268,7 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal)
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 snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
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
* 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
* the face edges — placement follows the snapped cursor as-is.
*/
function resolveRoofWallTarget(
ctx: PlacementContext,
event: RoofEvent,
shiftFree = false,
freePlace = false,
): RoofWallTarget | null {
const attachTo = ctx.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
@@ -414,10 +416,10 @@ function resolveRoofWallTarget(
const dims = getGridAlignedDimensions(rawDims, attachTo)
const [width, height] = dims
const u = shiftFree ? hit.u : snapToHalf(hit.u)
const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
if (!fitted && !shiftFree) return null
const u = freePlace ? hit.u : snapToHalf(hit.u)
const centerV = (freePlace ? hit.v : snapToHalf(hit.v)) + height / 2
const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
if (!fitted && !freePlace) return null
const finalU = fitted?.u ?? u
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
* isn't over a placeable face.
*/
enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null {
const target = resolveRoofWallTarget(ctx, event, shiftFree)
enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null {
const target = resolveRoofWallTarget(ctx, event, freePlace)
if (!target) return null
return {
@@ -511,11 +513,11 @@ export const roofWallStrategy = {
* segment transitions inside one roof never re-fire roof:enter) or to
* 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.draftItem) return null
const target = resolveRoofWallTarget(ctx, event, shiftFree)
const target = resolveRoofWallTarget(ctx, event, freePlace)
if (!target) 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.
*/
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.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.
if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null
if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
return {
nodeUpdate: {
@@ -615,7 +617,7 @@ export const ceilingStrategy = {
// Ceiling items are stored in ceiling-local coordinates, so snapping must
// 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
? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
@@ -654,7 +656,7 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const bypassSnap = event.nativeEvent?.shiftKey === true
const bypassSnap = event.nativeEvent?.altKey === true
const x = bypassSnap
? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
@@ -771,7 +773,7 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
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 z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight
@@ -823,7 +825,7 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
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 z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight
@@ -924,7 +926,7 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
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 z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
@@ -969,7 +971,7 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
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 z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
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 { sfxEmitter } from '../../../lib/sfx-bus'
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 {
createLineGeometry,
@@ -221,7 +221,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
shelfId: null,
},
)
const shiftFreeRef = useRef(false)
const altFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null)
// 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;
@@ -441,7 +441,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
})
const getActiveValidators = () =>
shiftFreeRef.current
altFreeRef.current
? {
canPlaceOnFloor: () => ({ valid: true }),
canPlaceOnWall: () => ({ valid: true }),
@@ -450,7 +450,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
: validators
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
edgeMaterial.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
// its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid
// (`event.position`) on its default path and only reads `event.localPosition`
// under Shift, so both frames must carry the offset; the world point is
// derived from the corrected local one so the two stay consistent.
// under Alt (free place), so both frames must carry the offset; the world
// point is derived from the corrected local one so the two stay consistent.
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
if (relativeFloorStart === null) return event
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
// 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
// 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
let alignX = 0
let alignZ = 0
const bypassSnap = floorEvent.nativeEvent?.shiftKey === true
const bypassAlign = floorEvent.nativeEvent?.altKey === true || bypassSnap
const freePlace = floorEvent.nativeEvent?.altKey === true
const bypassAlign = freePlace || !isMagneticSnapActive()
if (!bypassAlign && draft) {
alignmentCandidates ??= collectAlignmentAnchors(
useScene.getState().nodes,
@@ -812,7 +814,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Play snap sound when grid position changes
if (
!bypassSnap &&
!freePlace &&
previousGridPos &&
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
) {
@@ -997,7 +999,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.z !== result.gridPosition[2]
// Play snap sound when grid position changes
if (event.nativeEvent?.shiftKey !== true && posChanged) {
if (event.nativeEvent?.altKey !== true && posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -1121,7 +1123,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// re-enters whenever the strategy reports a segment change.
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
event.stopPropagation()
@@ -1152,7 +1154,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return
}
const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current)
const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
if (!result) {
// Different segment under the pointer (or no placeable face) —
// 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.z !== result.gridPosition[2]
if (!shiftFreeRef.current && posChanged) {
if (!altFreeRef.current && posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -1210,7 +1212,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onRoofWallClick = (event: RoofEvent) => {
const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current)
const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
if (!result) return
event.stopPropagation()
@@ -1220,7 +1222,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
if (enterResult) {
applyTransition(enterResult)
} else {
@@ -1261,7 +1263,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.position[1],
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 wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2
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.z !== result.gridPosition[2]
if (event.nativeEvent?.shiftKey !== true && posChanged) {
if (event.nativeEvent?.altKey !== true && posChanged) {
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.
const ROTATION_STEP = Math.PI / 4
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftFreeRef.current = true
if (event.key === 'Alt') {
altFreeRef.current = true
revalidate()
return
}
@@ -1908,8 +1910,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftFreeRef.current = false
if (event.key === 'Alt') {
altFreeRef.current = false
revalidate()
}
}
@@ -1997,6 +1999,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('shelf:move', onShelfMove)
emitter.on('shelf:click', onShelfClick)
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)
return () => {
@@ -2032,6 +2053,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('shelf:move', onShelfMove)
emitter.off('shelf:click', onShelfClick)
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)
window.removeEventListener('keydown', onKeyDown)
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).
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
useFrame((_, delta) => {
useFrame(() => {
if (!asset) {
reconcileDraftRaycast(null)
return
@@ -2145,12 +2171,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
mesh.visible = true
if (placementState.current.surface === 'floor') {
const distance = mesh.position.distanceToSquared(gridPosition.current)
if (distance > 1) {
// Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
// active move visibly trail the cursor and — combined with React
// re-renders momentarily pulling the mesh back toward its committed
// position — read as a laggy snap-back on every move. Copying each frame
// 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)
} else {
mesh.position.lerp(gridPosition.current, delta * 20)
}
// Adjust Y for slab elevation (floor items on top of slabs)
if (!asset.attachTo) {
@@ -30,7 +30,8 @@ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
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 { CursorSphere } from '../shared/cursor-sphere'
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
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
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
}
@@ -244,10 +247,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// 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).
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.
const validRef = useRef(true)
const shiftRef = useRef(false)
const altRef = useRef(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
@@ -259,7 +262,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
dragAnchorRef.current = null
hasMovedRef.current = false
rotationRef.current = originalRotationY
shiftRef.current = false
altRef.current = false
validRef.current = true
// Re-sync the box transform to the (possibly new) node. `node` changes
// 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))
// 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 user can drop on top of an existing item on purpose. Only shelves
// show the box, so this no-ops for every other movable kind.
// the result to the box colour. Alt (free place) forces a valid (green)
// override so the user can drop on top of an existing item on purpose. Only
// shelves show the box, so this no-ops for every other movable kind.
const recomputeValidity = () => {
if (!boxDimensions) return
if (shiftRef.current) {
if (altRef.current) {
validRef.current = true
setValid(true)
return
@@ -417,7 +420,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchorRef.current,
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
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,
// snap and publish a guide. The guide connects to the nearest real
// corner of the candidate (resolver tie-break), so the dot always sits
// on an actual point. Alt bypasses alignment; Shift bypasses all snap.
const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true
// on an actual point. Alt (free place) bypasses all snap; the active
// snapping mode governs whether magnetic alignment runs at all.
const freePlace = event.nativeEvent?.altKey === true
const bypass = freePlace || !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
@@ -488,7 +493,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
previewConnectivity(position, rotationRef.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')
previousSnapRef.current = [x, z]
}
@@ -524,9 +529,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// deliberate drop. Prevents preset re-arm from double-placing.
if (!hasMovedRef.current) return
// Refuse a drop on an invalid (red) footprint, matching the GLB item
// tool — unless Shift is held to force placement. Other kinds carry no
// validity box (`validRef` stays true), so they're never blocked.
if (!validRef.current && !shiftRef.current) return
// tool — unless Alt (free place) is held to force placement. Other kinds
// carry no validity box (`validRef` stays true), so they're never blocked.
if (!validRef.current && !altRef.current) return
const position: [number, number, number] = [...lastCursorRef.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
// imperatively + mirrored to the live transform; committed on drop.
const onKeyDown = (e: KeyboardEvent) => {
// Hold Shift to force placement on an invalid (red) footprint, matching
// the GLB item tool. Recolour the box to green while held.
if (e.key === 'Shift') {
shiftRef.current = true
// Hold Alt (free place) to force placement on an invalid (red) footprint,
// matching the GLB item tool. Recolour the box to green while held.
if (e.key === 'Alt') {
altRef.current = true
recomputeValidity()
return
}
@@ -659,8 +664,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
recomputeValidity()
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftRef.current = false
if (e.key === 'Alt') {
altRef.current = false
recomputeValidity()
}
}
@@ -4,6 +4,7 @@ import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import { Box3, type Camera, type Object3D, Vector3 } from 'three'
import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import {
clearBoxSelectHandled,
isBoxSelectPointerSuppressed,
@@ -191,6 +192,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
const currentClientXRef = useRef(0)
const currentClientYRef = useRef(0)
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(
(nextIds: string[]) => {
@@ -206,6 +213,11 @@ const ScreenRectangleSelectTool: React.FC = () => {
pointerDownRef.current = false
isDraggingRef.current = false
pointerIdRef.current = null
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current)
previewRafRef.current = null
}
pendingPreviewRectRef.current = null
hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([])
@@ -213,6 +225,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
useViewer.getState().setInputDragging(false)
ownsInputDraggingRef.current = false
}
useInteractionScope.getState().endIf((s) => s.kind === 'box-select')
}, [syncPreviewSelectedIds])
useEffect(() => {
@@ -263,6 +276,14 @@ const ScreenRectangleSelectTool: React.FC = () => {
useEffect(() => {
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) => {
if (!pointerDownRef.current) return
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
@@ -291,6 +312,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
isDraggingRef.current = true
ownsInputDraggingRef.current = true
useViewer.getState().setInputDragging(true)
useInteractionScope.getState().begin({ kind: 'box-select' })
markBoxSelectHandled()
try {
canvas.setPointerCapture(event.pointerId)
@@ -311,13 +333,22 @@ const ScreenRectangleSelectTool: React.FC = () => {
screenRectFromDomRect(canvas.getBoundingClientRect()),
)
if (!clampedRect) {
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current)
previewRafRef.current = null
}
pendingPreviewRectRef.current = null
hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([])
return
}
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) => {
@@ -56,6 +56,7 @@ export const ToolManager: React.FC = () => {
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode)
const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
@@ -134,6 +135,16 @@ export const ToolManager: React.FC = () => {
// Show build tools when in build mode
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
// tool contribution, the registry-driven tool takes over.
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 */}
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{movingNode?.type === 'building' && (
{showMover && movingNode?.type === 'building' && (
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
)}
@@ -259,7 +270,7 @@ export const ToolManager: React.FC = () => {
</Suspense>
) : null
})()}
{movingNode && movingNode.type !== 'building' && (
{showMover && movingNode.type !== 'building' && (
<MoveTool
onNodeMoved={handlePlacedNodeSelected}
onSpawnMoved={handlePlacedNodeSelected}
@@ -13,7 +13,8 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
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 {
distanceSquared,
findWallSnapTarget,
@@ -51,10 +52,16 @@ type WallSplitIntersection = {
}
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 {
if (step <= 0) return value
return Math.round(value / step) * step
}
@@ -404,6 +411,11 @@ export function createWallOnCurrentLevel(
let resolvedStart = start
let resolvedEnd = end
// The corner-join / wall-split snap on commit is a magnetic (line) snap, so
// it must be gated by the snapping mode like the draft preview is. Without
// this gate `'off'` (and `'angles'`) still snapped the committed endpoint to
// existing wall geometry — the residual snap the draft path no longer does.
if (isMagneticSnapActive()) {
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
const splitEnd = splitWallIfNeeded(
endIntersection,
@@ -431,6 +443,7 @@ export function createWallOnCurrentLevel(
workingWalls = splitStart.walls
resolvedStart = splitStart.point
}
}
if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
return null
@@ -1,35 +1,129 @@
import { Icon } from '@iconify/react'
import type { ContextualShortcutHint } from '../../../lib/contextual-help'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
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[] }) {
return (
<div className="flex flex-wrap items-center gap-0.5">
<div className="flex shrink-0 items-center gap-1">
{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}
<ShortcutToken className="h-5 px-1.5 text-[10px]" value={key} />
<ShortcutToken className="h-6 px-1.5 text-[10px]" value={key} />
</div>
))}
</div>
)
}
export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) {
if (hints.length === 0) return null
const SNAPPING_MODE_ICONS = {
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 (
<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) => (
<div
className={cn(
'grid min-w-0 grid-cols-1 gap-1 rounded-md text-sm',
hint.active && '-mx-1 bg-primary/10 px-1.5 py-1 text-foreground',
PILL_CLASS,
'w-full justify-between',
hint.active && 'border-primary/40 bg-primary/10 text-foreground',
)}
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} />
<span className="min-w-0 text-muted-foreground text-xs leading-snug">{hint.label}</span>
</div>
))}
</div>
@@ -10,7 +10,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
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 useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper'
@@ -62,6 +66,7 @@ export function HelperManager() {
const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode)
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
const selectedIds = useViewer((s) => s.selection.selectedIds)
const isMobile = useIsMobile()
const modifiers = useActiveModifierKeys()
@@ -87,9 +92,16 @@ export function HelperManager() {
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
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.type === 'building') return <BuildingHelper showRotate />
return <ItemHelper shiftPressed={modifiers.shift} showEsc />
return <ItemHelper showEsc />
}
if (mode === 'material-paint') {
@@ -2,21 +2,18 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
interface ItemHelperProps {
showEsc?: boolean
shiftPressed?: boolean
}
export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) {
export function ItemHelper({ showEsc }: ItemHelperProps) {
return (
<ContextualHelperPanel
showSnapping
hints={[
{ keys: ['Left click'], label: 'Place item' },
{ keys: ['R'], label: 'Rotate counterclockwise' },
{ keys: ['T'], label: 'Rotate clockwise' },
{
keys: ['Shift'],
label: shiftPressed ? 'Guided constraints bypassed' : 'Free place',
active: shiftPressed,
},
{ keys: ['Shift'], label: 'Cycle snapping mode' },
{ keys: ['Alt'], label: 'Free place (no snap)' },
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
]}
/>
@@ -20,12 +20,19 @@ export function RegisteredToolHelper({
if (hints.length === 0) return null
return (
<ContextualHelperPanel
hints={hints.map((hint) => ({
showSnapping
hints={hints.map((hint) => {
// Shift is a per-kind bypass for item / opening / zone / duct placement
// ("Free place", "Free angle", …) — those hints flip to a bypassed
// 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 && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label,
active: shiftPressed && hint.key === 'Shift',
}))}
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 }) {
return (
<ContextualHelperPanel
showSnapping
hints={[
{ 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 { useFrame } from '@react-three/fiber'
import type { Mesh } from 'three'
import { resolveOverlayPolicy } from '../lib/interaction/overlay-policy'
import useEditor from '../store/use-editor'
import useInteractionScope from '../store/use-interaction-scope'
export const ViewerZoneSystem = () => {
useFrame(() => {
const { levelId, zoneId } = useViewer.getState().selection
const structureLayer = useEditor.getState().structureLayer
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) => {
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)
const showLabel = !!levelId && isOnSelectedLevel
const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
const targetOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${id}-label`)
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')
}
// 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) => {
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
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
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') {
e.preventDefault()
_toolCancelConsumed = false
@@ -91,6 +135,9 @@ export const useKeyboard = ({
e.preventDefault()
useEditor.getState().setPhase('furnish')
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')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
@@ -98,6 +145,8 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('zones')
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) {
e.preventDefault()
@@ -109,6 +158,9 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
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) {
if (isVersionPreviewMode) return
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)
return () => window.removeEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [disabled, isVersionPreviewMode])
return null
+1 -1
View File
@@ -327,7 +327,7 @@ export type {
ViewMode,
WorkspaceMode,
} from './store/use-editor'
export { default as useEditor } from './store/use-editor'
export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from './store/use-editor'
export {
default as useOpeningGuides,
type OpeningGuide3D,
@@ -49,7 +49,10 @@ describe('resolveSelectModeHelpHints', () => {
keys: ['Cmd/Ctrl', 'Right click'],
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'],
label: 'Hold to bypass snaps and angle steps',
active: false,
@@ -4,6 +4,25 @@ export type ContextualShortcutHint = {
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 = {
selectedCount: number
hasMovableSelection: boolean
@@ -79,11 +98,16 @@ export function resolveSelectModeHelpHints({
}
}
// The Shift bypass only applies to an in-progress direct move/rotate
// (the Cmd/Ctrl-drag gesture), so only surface it while that modifier is
// engaged — not on an idle selection, where Shift means multi-select.
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) {
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]
}
// 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(
center: Point2D,
width: number,
@@ -8,6 +8,7 @@ export {
export {
clampPlanValue,
doesPolygonIntersectSelectionBounds,
floorplanLocalToWorldPoint,
getDistanceToWallSegment,
getFloorplanSelectionBounds,
getPlanPointDistance,
@@ -20,6 +21,7 @@ export {
movePlanPointTowards,
pointMatchesWallPlanPoint,
rotatePlanVector,
worldToFloorplanLocalPoint,
} from './geometry'
export {
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.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,
} from '../components/tools/wall/wall-drafting'
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'
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 walls = getLevelWalls(nodes, input.levelId, input.walls)
const fallbackPoint = input.fallbackPoint
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap
const magnetic = input.magnetic ?? isMagneticSnapActive()
const wallSnap = snapWallDraftPointDetailed({
point: input.rawPoint,
+164 -17
View File
@@ -40,6 +40,14 @@ import {
resolvePaintTargetFromSelection,
type SingleSurfaceMaterialRole,
} 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_FLOORPLAN_PANE_RATIO = 0.5
@@ -374,11 +382,20 @@ type EditorState = {
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep
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
// wall corners / wall bodies (the "magnetic" beacon). Independent of grid
// snap. On by default; toggled from the Display menu.
magneticSnap: boolean
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
toggleReferenceFloor: () => void
setShowReferenceFloor: (show: boolean) => void
@@ -427,6 +444,7 @@ type PersistedEditorLayoutState = Pick<
| 'floorplanSelectionTool'
| 'gridSnapStep'
| 'magneticSnap'
| 'snappingMode'
| 'showReferenceFloor'
| 'referenceFloorOffset'
| 'referenceFloorOpacity'
@@ -450,6 +468,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
floorplanSelectionTool: 'click',
gridSnapStep: 0.5,
magneticSnap: true,
snappingMode: DEFAULT_SNAPPING_MODE,
showReferenceFloor: false,
referenceFloorOffset: 1,
referenceFloorOpacity: 0.35,
@@ -568,6 +587,9 @@ function normalizePersistedEditorLayoutState(
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
// Default on: only an explicit persisted `false` disables it.
magneticSnap: state?.magneticSnap !== false,
snappingMode: SNAPPING_MODES.includes(state?.snappingMode as SnappingMode)
? (state?.snappingMode as SnappingMode)
: DEFAULT_SNAPPING_MODE,
showReferenceFloor: state?.showReferenceFloor === true,
referenceFloorOffset:
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -760,6 +782,10 @@ const useEditor = create<EditorState>()(
else if (tool) {
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,
setTool: (tool) => set({ tool }),
@@ -814,25 +840,68 @@ const useEditor = create<EditorState>()(
| null,
placementDragMode: false,
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
setMovingNode: (node) =>
set(
node === null
? // Preserve `movingNodeOrigin` across the clear so the
// non-owning side's effect cleanup — which fires after
// `setMovingNode(null)` propagates — can still read who
// finalised. The next non-null `setMovingNode` resets it.
// Always clear the press-drag flag when a move ends.
{ movingNode: null, placementDragMode: false }
: { movingNode: node, movingNodeOrigin: null },
),
setMovingNode: (node) => {
const scope = useInteractionScope.getState()
if (node === null) {
scope.endIf((s) => s.kind === 'placing' || s.kind === 'moving')
// Preserve `movingNodeOrigin` across the clear so the non-owning
// side's effect cleanup — which fires after `setMovingNode(null)`
// propagates — can still read who finalised. The next non-null
// `setMovingNode` resets it. Always clear the press-drag flag.
set({ movingNode: null, placementDragMode: false })
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,
setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
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,
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,
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',
cycleRotationAxis: () => {
const order = ['y', 'x', 'z'] as const
@@ -841,9 +910,31 @@ const useEditor = create<EditorState>()(
return next
},
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,
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,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
activePaintMaterial: null,
@@ -925,7 +1016,24 @@ const useEditor = create<EditorState>()(
spaces: {},
setSpaces: (spaces) => set({ spaces }),
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,
setHoveredHole: (hole) =>
set((state) =>
@@ -1007,8 +1115,22 @@ const useEditor = create<EditorState>()(
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
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,
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,
toggleReferenceFloor: () =>
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -1101,6 +1223,7 @@ const useEditor = create<EditorState>()(
floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep,
magneticSnap: state.magneticSnap,
snappingMode: state.snappingMode,
showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset,
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
@@ -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
// direction + perpendicular for the cutout footprint.
floorplan: buildDoorFloorplan,
floorplanDependsOnSiblings: true,
// Stage D — placement (`def.tool`) + move-on-wall (`def.
// affordanceTools.move`). Both ports of the legacy tools at
// `editor/components/tools/door/`, relocated into the kind folder and
@@ -11,6 +11,7 @@ import {
} from '@pascal-app/core'
import {
type FencePlanPoint,
isMagneticSnapActive,
isSegmentLongEnough,
snapFenceDraftPoint,
useAlignmentGuides,
@@ -171,6 +172,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
fences: ctx.levelFences,
ignoreFenceIds: [ctx.fenceId as string],
bypassSnap: modifiers.shift,
magnetic: !modifiers.shift && isMagneticSnapActive(),
})
// Figma-style alignment: nudge the dragged endpoint onto another wall /
-1
View File
@@ -229,7 +229,6 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
toolHints: [
{ key: 'Left click', label: 'Set fence start / end' },
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' },
],
@@ -15,6 +15,7 @@ import {
alignFloorplanDraftPoint,
type FencePlanPoint,
getSegmentGridStep,
isMagneticSnapActive,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
snapFenceDraftPoint,
@@ -165,7 +166,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
fences: nextFences,
ignoreFenceIds: [node.id],
bypassSnap: modifiers.shiftKey,
magnetic: !modifiers.shiftKey,
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
})
// Figma-style alignment on the dragged endpoint — snaps it onto
+2
View File
@@ -15,6 +15,7 @@ import {
import {
CursorSphere,
consumePlacementDragRelease,
isMagneticSnapActive,
markToolCancelConsumed,
snapFenceDraftPoint,
triggerSFX,
@@ -201,6 +202,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
fences: levelFences,
ignoreFenceIds: [fenceId],
bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
})
if (
+29 -37
View File
@@ -24,6 +24,8 @@ import {
getAngleArcToSegmentReference,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
isAngleSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
type SegmentAngleReference,
snapFenceDraftPoint,
@@ -445,7 +447,6 @@ export const FenceTool: React.FC = () => {
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const measurementColor = isDark ? '#ffffff' : '#111111'
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
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
// snapping. Returns the possibly snapped point.
// publish the guide. Alt bypasses alignment. Returns the possibly snapped
// point.
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()
return point
}
@@ -494,13 +498,13 @@ export const FenceTool: React.FC = () => {
if (!(cursorRef.current && previewRef.current)) return
const { walls, fences } = getCurrentLevelElements()
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
// While drafting, the segment locks to 15° rays from its start
// unless Shift is held. Shift also bypasses grid and magnetic snap.
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
// While drafting, the segment locks to 15° rays from its start.
// Snapping is governed by the snapping mode (`'off'` is the bypass);
// there is no Shift hold-to-bypass. Alt still bypasses alignment guides.
const bypassAlign = event.nativeEvent?.altKey === true
if (buildingState.current === 1) {
const angleLocked = !bypassSnap
const angleLocked = isAngleSnapActive()
const snappedLocal = alignPoint(
snapFenceDraftPoint({
point: localPoint,
@@ -508,7 +512,7 @@ export const FenceTool: React.FC = () => {
fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: isMagneticSnapActive(),
}),
bypassAlign || angleLocked,
)
@@ -516,7 +520,6 @@ export const FenceTool: React.FC = () => {
cursorRef.current.position.copy(endingPoint.current)
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
if (
!bypassSnap &&
previousFenceEnd &&
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
) {
@@ -543,7 +546,12 @@ export const FenceTool: React.FC = () => {
)
} else {
const snappedPoint = alignPoint(
snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }),
snapFenceDraftPoint({
point: localPoint,
walls,
fences,
magnetic: isMagneticSnapActive(),
}),
bypassAlign,
)
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 localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
const bypassAlign = event.nativeEvent?.altKey === true
if (buildingState.current === 0) {
const snappedStart = alignPoint(
snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }),
snapFenceDraftPoint({
point: localClick,
walls,
fences,
magnetic: isMagneticSnapActive(),
}),
bypassAlign,
)
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
@@ -574,7 +586,7 @@ export const FenceTool: React.FC = () => {
previewRef.current.visible = true
setDraftMeasurement(null)
} else {
const angleLocked = !bypassSnap
const angleLocked = isAngleSnapActive()
const snappedEnd = alignPoint(
snapFenceDraftPoint({
point: localClick,
@@ -582,7 +594,7 @@ export const FenceTool: React.FC = () => {
fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: isMagneticSnapActive(),
}),
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 = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
@@ -638,17 +636,11 @@ export const FenceTool: React.FC = () => {
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
useSegmentDraftChain.getState().clear('fence')
useAlignmentGuides.getState().clear()
}
+1
View File
@@ -162,6 +162,7 @@ export const gutterDefinition: NodeDefinition<typeof GutterNode> = {
parametrics: gutterParametrics,
handles: gutterHandles,
floorplan: buildGutterFloorplan,
floorplanDependsOnSiblings: true,
renderer: {
kind: 'parametric',
+2 -1
View File
@@ -315,7 +315,8 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
{ key: 'Left click', label: 'Place item' },
{ key: 'R', label: 'Rotate counterclockwise' },
{ 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' },
],
+5 -2
View File
@@ -182,6 +182,7 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
})
const isSelected = ctx.viewState?.selected ?? false
const isMoving = ctx.viewState?.moving ?? false
const floorPlanUrl = node.asset.floorPlanUrl
const children: FloorplanGeometry[] = [
{
@@ -214,8 +215,10 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
rotation: transform.rotation,
})
}
// Move handle — orange dot at the item center. Only when selected.
if (isSelected) {
// Move handle — orange dot at the item center. Only when selected and not
// 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({
kind: 'move-handle',
point: [cx, cy],
+4 -1
View File
@@ -23,6 +23,7 @@ import {
consumePlacementDragRelease,
DragBoundingBox,
getFloorStackPreviewPosition,
isMagneticSnapActive,
resolvePlanarCursorPosition,
snapFenceDraftPoint,
stripPlacementMetadataFlags,
@@ -290,11 +291,13 @@ export const MoveRoofTool: React.FC<{
const y = event.position[1]
const roofBypassSnap = event.nativeEvent?.shiftKey === true
const snappedLocal = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
bypassSnap: event.nativeEvent?.shiftKey === true,
bypassSnap: roofBypassSnap,
magnetic: !roofBypassSnap && isMagneticSnapActive(),
})
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
const [rawLocalX, rawLocalZ] = computeLocal(
+2
View File
@@ -18,6 +18,7 @@ import {
CursorSphere,
consumePlacementDragRelease,
getSegmentGridStep,
isMagneticSnapActive,
markToolCancelConsumed,
resolveAlignmentForActiveBuilding,
snapBuildingLocalToWorldGrid,
@@ -174,6 +175,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
walls: levelWalls,
fences: levelFences,
bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
})
+7 -5
View File
@@ -1,5 +1,5 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildWallFloorplan } from './floorplan'
import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
import { wallFloorplanMoveTarget } from './floorplan-move'
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
@@ -18,7 +18,8 @@ import { wallSlots } from './slots'
* `renderer` + `system` keep wrap-exporting legacy WallRenderer +
* WallSystem + WallCutout.
* 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
* wall is registered.
*/
@@ -98,9 +99,11 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
// Priority 4 mirrors the legacy WallSystem's useFrame priority.
priority: 4,
},
// Stage C: floor-plan rendering. ctx.siblings provides other walls in
// the level so `calculateLevelMiters` can compute correct corner joins.
// Stage C: floor-plan rendering. Precomputes the level miter graph once
// per render pass, then the builder reads its own junctions by wall id.
computeFloorplanLevelData: computeWallFloorplanLevelData,
floorplan: buildWallFloorplan,
floorplanDependsOnSiblings: true,
// 2D drag affordances triggered by `endpoint-handle` primitives in
// `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the
// same legacy `MoveWallEndpointTool` flow, reachable from both the
@@ -114,7 +117,6 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
toolHints: [
{ key: 'Left click', label: 'Set wall start / end' },
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' },
],
@@ -13,6 +13,7 @@ import {
import {
alignFloorplanDraftPoint,
getSegmentGridStep,
isMagneticSnapActive,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
snapScalarToGrid,
@@ -193,7 +194,7 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
walls,
ignoreWallIds: [node.id],
bypassSnap: modifiers.shiftKey,
magnetic: !modifiers.shiftKey,
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
})
// Figma-style alignment on the dragged corner — snaps it onto another
+27 -13
View File
@@ -8,6 +8,7 @@ import {
getWallMidpointHandlePoint,
getWallPlanFootprint,
isCurvedWall,
type WallMiterData,
type WallNode,
} from '@pascal-app/core'
@@ -35,6 +36,15 @@ function formatLengthMetric(meters: number): string {
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
* 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.
* 5. A small dimension label at the midpoint when selected.
*
* `ctx.siblings` provides other walls in the level so
* `calculateLevelMiters` computes correct corner joins.
*
* 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.
* `ctx.levelData` provides the shared level miter graph when the floor-plan
* dispatcher precomputes it; `ctx.siblings` remains the fallback path for
* direct builder callers.
*/
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
const siblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall')
const all = [node, ...siblings].map(exaggerateWallThickness)
const miters = calculateLevelMiters(all)
const self = all.find((w) => w.id === node.id)
if (!self) return null
const self = exaggerateWallThickness(node)
// Prefer the level-batch miter graph the floor-plan dispatcher precomputes
// once per pass (`computeWallFloorplanLevelData`). Only the fallback path —
// a direct builder caller with no shared data — pays the O(N) exaggerate +
// 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)
if (!polygon || polygon.length < 3) return null
@@ -19,6 +19,7 @@ import {
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
isMagneticSnapActive,
isSegmentLongEnough,
MeasurementPill,
type MovingWallEndpoint,
@@ -295,7 +296,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
walls: levelWalls,
ignoreWallIds: [nodeId],
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
})
const snappedPoint = snapResult.point
+20 -42
View File
@@ -20,6 +20,8 @@ import {
getAngleArcToSegmentReference,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
isAngleSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
type SegmentAngleReference,
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
* start, click 2 creates the wall. Between clicks a vertical preview
* rectangle + length/angle measurement HUD follow the pointer. Shift
* bypasses the angle snap; Esc cancels.
* rectangle + length/angle measurement HUD follow the pointer. Snapping is
* governed by the global snapping mode (`'off'` is the bypass); Esc cancels.
*
* Not a `DragAction` — same reasoning as fence/slab/ceiling placement:
* 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 endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
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
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
// snapping. Returns the possibly snapped point.
// publish the guide. Alt bypasses alignment. Returns the possibly snapped
// point.
const alignPoint = (
point: WallPlanPoint,
options: { applySnap?: boolean; bypass?: boolean },
): 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()
return point
}
@@ -546,19 +550,17 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls()
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Default path: grid + magnetic snap, with 15° angle lock while
// drafting. Shift is a hard snap bypass: no grid, magnetic, angle,
// or alignment snap.
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const angleLocked = buildingState.current === 1 && !bypassSnap
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
// Snapping is governed entirely by the snapping mode (grid / lines /
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
// Alt still bypasses Figma-style alignment guides independently.
const angleLocked = buildingState.current === 1 && isAngleSnapActive()
const bypassAlign = event.nativeEvent?.altKey === true
const snapResult = snapWallDraftPointDetailed({
point: localPoint,
walls,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: isMagneticSnapActive(),
})
gridPosition = alignPoint(snapResult.point, {
applySnap: !angleLocked,
@@ -590,7 +592,6 @@ export const WallTool: React.FC = () => {
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if (
!bypassSnap &&
previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
) {
@@ -633,16 +634,14 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls()
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
const bypassAlign = event.nativeEvent?.altKey === true
if (buildingState.current === 0) {
const snappedStart = alignPoint(
snapWallDraftPointDetailed({
point: localClick,
walls,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: isMagneticSnapActive(),
}).point,
{ bypass: bypassAlign },
)
@@ -665,15 +664,14 @@ export const WallTool: React.FC = () => {
// `onGridMove` writes a real BoxGeometry skips that frame.
setDraftMeasurement(null)
} else if (buildingState.current === 1) {
const angleLocked = !bypassSnap
const angleLocked = isAngleSnapActive()
const snappedEnd = alignPoint(
snapWallDraftPointDetailed({
point: localClick,
walls,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
magnetic: isMagneticSnapActive(),
}).point,
{
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 = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
@@ -753,17 +737,11 @@ export const WallTool: React.FC = () => {
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
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
// + thickness — same shape as door.
floorplan: buildWindowFloorplan,
floorplanDependsOnSiblings: true,
// Stage D — placement + move-on-wall. Same recipe as door. See
// `nodes/src/window/{tool,move-tool,window-math}.ts`.
tool: () => import('./tool'),
@@ -1,16 +1,19 @@
import {
type AnyNodeId,
clampDoorOperationState,
DEFAULT_WALL_THICKNESS,
type DoorNode,
DoorNode as DoorNodeSchema,
getDoorRenderOpenAmount,
getEffectiveNode,
getWallThickness,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
@@ -26,6 +29,7 @@ import {
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -186,6 +190,19 @@ function tagDoorSlot(mesh: THREE.Mesh): THREE.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 {
const slots = node.slots
if (!slots) return false
@@ -1323,6 +1340,14 @@ function addDoorLeaf(
)
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(
@@ -2556,18 +2581,21 @@ function hideEmptyGeometryMeshes(root: THREE.Object3D) {
}
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
if (!cutout) {
cutout = new THREE.Mesh()
cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// wins the scene raycast over the wall in front of the recessed door body —
// making it the selection AND paint hit target for the whole opening. The
// paint capability then re-raycasts the door's parts to find the slot.
// The cutout (invisible) is proud of the wall on both faces, so it wins the
// scene raycast over the wall in front of the recessed door body — making it
// the selection AND paint hit target for the whole opening. The paint
// 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)
}
cutout.geometry.dispose()
const depth = resolveOpeningCutoutProxyDepth(node)
const openingShape = getEffectiveOpeningShape(node)
if (openingShape === 'arch') {
cutout.geometry = new THREE.ExtrudeGeometry(
@@ -2579,12 +2607,12 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else if (openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedTopShape(
@@ -2595,18 +2623,30 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
getDoorTopRadii(node, node.width, node.height),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} 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
}
// 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.
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
@@ -10,6 +10,20 @@ export type OpeningCutoutRect = {
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 = {
topLeft: number
topRight: number
@@ -1,12 +1,15 @@
import {
type AnyNodeId,
DEFAULT_WALL_THICKNESS,
getEffectiveNode,
getWallThickness,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
useInteractive,
useLiveNodeOverrides,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
@@ -22,6 +25,7 @@ import {
resolveMaterialRef,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -163,6 +167,21 @@ function tagWindowSlot(mesh: THREE.Mesh): THREE.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 {
const slots = node.slots
if (!slots) return false
@@ -1020,6 +1039,8 @@ function addRectCasementSash(
)
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
disableSubtreeRaycastIfSwung(sash, rotationY)
}
function addFrenchCasementHingeMarkers(
@@ -1244,6 +1265,7 @@ function addShapedFrenchCasementSash(
sashDepth * 0.08,
)
}
disableSubtreeRaycastIfSwung(sash, rotationY)
return
}
@@ -1271,6 +1293,7 @@ function addShapedFrenchCasementSash(
sashDepth * 0.08,
)
}
disableSubtreeRaycastIfSwung(sash, rotationY)
}
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'
addBox(
mesh,
@@ -1696,6 +1721,8 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
currentWindowSlot = 'glass'
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.
currentWindowSlot = 'frame'
addBox(
@@ -3597,20 +3624,23 @@ function updateWindowMesh(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
if (!cutout) {
cutout = new THREE.Mesh()
cutout.name = 'cutout'
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// wins the scene raycast over the wall in front of the recessed window —
// making it the selection AND paint hit target for the whole opening. The
// paint capability then re-raycasts the window's parts to find the slot.
// The cutout (invisible) is proud of the wall on both faces, so it wins the
// scene raycast over the wall in front of the recessed window — making it
// the selection AND paint hit target for the whole opening. The paint
// 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)
}
cutout.geometry.dispose()
const depth = resolveOpeningCutoutProxyDepth(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') {
cutout.geometry = new THREE.ExtrudeGeometry(
createArchShape(
@@ -3621,12 +3651,12 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
getClampedArchHeight(node.width, node.height, node.archHeight),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} else if (node.openingShape === 'rounded') {
cutout.geometry = new THREE.ExtrudeGeometry(
createRoundedShape(
@@ -3637,18 +3667,30 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
getWindowRoundedRadii(node, node.width, node.height),
),
{
depth: 1,
depth,
bevelEnabled: false,
curveSegments: 24,
},
)
cutout.geometry.translate(0, 0, -0.5)
cutout.geometry.translate(0, 0, -depth / 2)
} 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
}
// 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.
* 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 |
| [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 |
| [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 |
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
| [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
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.
+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`).
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
+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`.
> **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
**Site**