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:
co-authored by
Claude Opus 4.8
parent
b2f1a8432e
commit
f773e6b8c5
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ export const gutterDefinition: NodeDefinition<typeof GutterNode> = {
|
||||
parametrics: gutterParametrics,
|
||||
handles: gutterHandles,
|
||||
floorplan: buildGutterFloorplan,
|
||||
floorplanDependsOnSiblings: true,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user