Merge remote-tracking branch 'origin/main' into fix/bake-door-window-anims

This commit is contained in:
Wassim SAMAD
2026-06-28 16:32:41 -04:00
189 changed files with 8853 additions and 3665 deletions
@@ -25,6 +25,7 @@ Required on every review. Read the remaining pages on demand when the diff touch
- `wiki/architecture/spatial-queries.md` - `wiki/architecture/spatial-queries.md`
- `wiki/architecture/node-schemas.md` - `wiki/architecture/node-schemas.md`
- `wiki/architecture/events.md` - `wiki/architecture/events.md`
- `wiki/architecture/interaction-scope.md` — the interaction state machine + the unified snapping/modifier convention. Read whenever the diff touches a tool, a `move-tool` / `selection` / endpoint / reshape file, `lib/interaction/**`, `lib/snapping-mode.ts`, or `use-interaction-scope`.
If anything in the diff looks like a new dispatch surface or registry concept, also skim the live charter at `plans/editor-node-registry.md` (in the private-editor repo) — it owns the current contract and which kind sits at which migration stage. If anything in the diff looks like a new dispatch surface or registry concept, also skim the live charter at `plans/editor-node-registry.md` (in the private-editor repo) — it owns the current contract and which kind sits at which migration stage.
@@ -119,6 +120,7 @@ If the PR adds or modifies a node kind, check against `wiki/architecture/node-de
- New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`. - New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`.
- **Node code does not import `useScene` directly.** A kind's geometry / system / tool should read and write through `SceneApi` (passed in by the framework) or `GeometryContext`. Direct `useScene.getState()` calls inside `packages/nodes/src/<kind>/` are a smell — they bypass the registry's IoC point and make the code harder to test. - **Node code does not import `useScene` directly.** A kind's geometry / system / tool should read and write through `SceneApi` (passed in by the framework) or `GeometryContext`. Direct `useScene.getState()` calls inside `packages/nodes/src/<kind>/` are a smell — they bypass the registry's IoC point and make the code harder to test.
- **Live drag motion is imperative, not store-driven.** Tools must not call `useLiveTransforms.set(...)` per `grid:move` tick to animate registered parametric kinds — the selector path doesn't reliably re-render and the mesh visibly disappears mid-drag. Use `sceneRegistry.nodes.get(node.id)?.position.set(x, y, z)` instead, and commit once at the end via `useScene.temporal.getState().resume() → updateNode → pause()`. The reference implementation is `MoveRegistryNodeTool`. This is the *only* sanctioned use of imperative mesh transforms by a tool; flag any other location that does the same. - **Live drag motion is imperative, not store-driven.** Tools must not call `useLiveTransforms.set(...)` per `grid:move` tick to animate registered parametric kinds — the selector path doesn't reliably re-render and the mesh visibly disappears mid-drag. Use `sceneRegistry.nodes.get(node.id)?.position.set(x, y, z)` instead, and commit once at the end via `useScene.temporal.getState().resume() → updateNode → pause()`. The reference implementation is `MoveRegistryNodeTool`. This is the *only* sanctioned use of imperative mesh transforms by a tool; flag any other location that does the same.
- **Data-driven drags preview via `useLiveNodeOverrides`, never per-tick `useScene`.** A kind whose geometry is recomputed from data fields (wall `start`/`end`, opening host-cut, endpoint reshape) previews by publishing field patches to `useLiveNodeOverrides` (merged by `getEffectiveWall` / `getEffectiveNode`), writing the scene store **once on commit**. A tool that calls `useScene.updateNodes`/`updateNode` on `grid:move` (or any per-pointer-move tick) is a **blocker** — it swaps the `nodes` map ref and re-renders every `useScene(s => s.nodes)` subscriber app-wide each frame (`markDirty` per tick is fine). Grep tell: `updateNode(s)?(` in an `onGridMove`/`onMove`/`applyPreview` path under `packages/nodes/src/<kind>/`. See `wiki/architecture/tools.md` § "Data-driven live drag".
### D. Selector performance ### D. Selector performance
@@ -126,6 +128,7 @@ If the PR adds or modifies a node kind, check against `wiki/architecture/node-de
- Selectors that return new object or array references each call (e.g. `s => ({ a: s.a, b: s.b })`, `s => s.items.filter(...)`) without a custom equality function (shallow or custom) are re-render hazards. - Selectors that return new object or array references each call (e.g. `s => ({ a: s.a, b: s.b })`, `s => s.items.filter(...)`) without a custom equality function (shallow or custom) are re-render hazards.
- Prefer subscribing by ID deep in the tree (one node per renderer) over subscribing to the full collection high up. - Prefer subscribing by ID deep in the tree (one node per renderer) over subscribing to the full collection high up.
- Inside a `<XxxPanel>` (legacy or `parametrics.customPanel`-mounted), avoid `useScene(s => s.nodes[selectedId])` as a callback dep — it changes every tick and pushes `useCallback` into infinite-loop territory. The recipe is in `plans/editor-node-registry.md` under "Panel slider-drag fix recipe". - Inside a `<XxxPanel>` (legacy or `parametrics.customPanel`-mounted), avoid `useScene(s => s.nodes[selectedId])` as a callback dep — it changes every tick and pushes `useCallback` into infinite-loop territory. The recipe is in `plans/editor-node-registry.md` under "Panel slider-drag fix recipe".
- **Per-node list renderers subscribe per-node, not to the whole live Map.** A list that draws one child per node (`FloorplanRegistryLayer``FloorplanRegistryEntry`) must have each child subscribe to its **own** slice (`useLiveTransforms(s => s.transforms.get(id))` / `overrides.get(id)`) and be `memo`'d with referentially stable props; the parent subscribes only to the stable id list. Subscribing the parent or a child to the whole `transforms`/`overrides` Map, dropping a `memo`, or passing unstable props re-renders all N children every drag tick — a flood that type-checks and passes tests. Sibling invalidation goes through a per-node epoch, not a whole-layer re-render. See `wiki/architecture/tools.md` § "Floorplan registry: per-node subscriptions".
### E. Separation of concerns ### E. Separation of concerns
@@ -135,6 +138,18 @@ If the PR adds or modifies a node kind, check against `wiki/architecture/node-de
- New node types are added by creating one folder under `packages/nodes/src/<kind>/` and registering its definition in `builtinPlugin.nodes`. Adding to a hand-maintained list elsewhere is a sign the registry hasn't absorbed that surface yet — check `plans/editor-node-registry.md` § "Known un-shimmed hardcoded lists" before assuming it's a violation. - New node types are added by creating one folder under `packages/nodes/src/<kind>/` and registering its definition in `builtinPlugin.nodes`. Adding to a hand-maintained list elsewhere is a sign the registry hasn't absorbed that surface yet — check `plans/editor-node-registry.md` § "Known un-shimmed hardcoded lists" before assuming it's a violation.
- `AnyNode` is hand-maintained for now (full runtime derivation would lose static typing); `packages/nodes/src/index.test.ts` is the drift gate. If a PR adds a kind to `AnyNode` without adding it to `builtinPlugin.nodes` (or vice versa), the parity test catches it — but flag it in review too. - `AnyNode` is hand-maintained for now (full runtime derivation would lose static typing); `packages/nodes/src/index.test.ts` is the drift gate. If a PR adds a kind to `AnyNode` without adding it to `builtinPlugin.nodes` (or vice versa), the parity test catches it — but flag it in review too.
### F. Interaction scope, snapping & modifiers
Apply when the diff touches a tool, a `move-tool` / `selection` / endpoint / reshape file, `lib/interaction/**`, `lib/snapping-mode.ts`, or `use-interaction-scope`. Source of truth: `wiki/architecture/interaction-scope.md` and `wiki/architecture/tools.md`.
- **No new `useEditor` interaction flag.** "What the user is doing" is owned by `useInteractionScope` (`begin` / `update` / `end` / `endIf`). A new `useEditor` boolean for an in-flight interaction (`moving…`, `curving…`, `dragging…`, `editing…`, `…InFlight`) is a **blocker** — it goes through the scope. The legacy mirror flags are being retired, not extended.
- **Snapping is mode-driven; Shift is not a bypass.** A tool / `move-tool` / `selection` file that reads `event.shiftKey`, `event.nativeEvent?.shiftKey`, or `modifiers.shiftKey` to **bypass snapping** (raw cursor, skip grid, skip angle) is a **blocker** — the convention is Shift = *cycle the mode*, Alt = force/free. Snap state must come from `isGridSnapActive()` / `isMagneticSnapActive()` / `isAngleSnapActive()`. Grep tell: `shiftKey` near a snap / step / `projectToAngleLock` / alignment expression in `packages/nodes/src/<kind>/{tool,move-tool,selection}.tsx`. (Shift for *multi-select* in select mode, or a documented topology opt-out, is fine — confirm which it is.)
- **No hardcoded, ungated grid step.** A quantize that isn't gated on `isGridSnapActive()` — always `useEditor.getState().gridSnapStep`, or a constant `WALL_GRID_STEP` / `0.5` / `getSegmentGridStep()` applied unconditionally — ignores the active mode and is a **blocker**. The gated form is `const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0`.
- **Snappable kinds declare `snapProfile`.** A kind whose tool snaps but whose `NodeDefinition` omits `snapProfile` (`'item' | 'structural'`) gets no contextual chip and the wrong default mode-set — flag it (suggestion, blocker if it ships a bespoke per-kind snapping switch instead).
- **Bespoke movers must not open a `moving` scope.** `useMovingNode()` reads the scope, and `tool-manager` mounts the generic `MoveRegistryNodeTool` whenever it's non-null. A bespoke `move-tool.tsx` that calls `begin(movingScope(...))` or `setMovingNode(node)` re-creates the dual-path double-handling (FPS collapse / teleport on move). **Blocker.** Mode-driven snapping inside a bespoke mover must resolve the mode without a global `moving` / `reshaping` scope (see `interaction-scope.md` § "Snapping mode & modifiers").
- **`event.altKey` is not an alignment bypass.** A drafting/preview path that reads `event.altKey` to suppress Figma-alignment is a **blocker** in any **new** or **touched** tool — alignment follows the magnetic snap mode (`bypass: !isMagneticSnapActive()`). Alt is force/free for placement/move; it is **not** a snap/alignment modifier. The one sanctioned Alt use outside force is the **wall/fence chain-mode toggle** (clean Alt-tap → `cycleWallChainMode` / `cycleFenceChainMode`, via `hooks/use-keyboard.ts` `isChainModeContext()`), allowed only because wall/fence drafting has no force role. Grep tell: `event.altKey` near an `align` / `bypass` expression in a `tool.tsx` / floorplan preview path.
- **Known-legacy exceptions (migrate on touch).** Tracked debt in `plans/editor-placement-interaction-overhaul.md`; a PR that **touches** one must migrate it, not extend it; a **new** tool on either legacy pattern is a blocker regardless. (1) `shiftKey` snap-bypass in the MEP move/endpoint tools (`packages/nodes/src/{duct-segment,pipe-segment,liquid-line,lineset,duct-fitting}/{move-tool,selection}.tsx`). (2) `altKey` alignment-bypass in the roof / polygon / slab pointer-move previews (`components/editor/floorplan-panel.tsx`) and the `resolveSlabPlanPointSnap` / `resolveCeilingPlanPointSnap` paths. **Already migrated — do not regress:** wall + fence drafting (both modifier patterns) and `zone` drafting (`components/tools/zone/zone-tool.tsx` — mode-driven grid/angle gates, no Shift bypass).
## 5. Output format ## 5. Output format
Group findings by severity: Group findings by severity:
+35 -11
View File
@@ -4,7 +4,7 @@ import { nodeRegistry } from '@pascal-app/core'
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes' import { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image' import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -152,15 +152,19 @@ function activateRoofFeatureTool(kind: string): void {
* with the kind's own `def.defaults()`. The "Painting" type swaps in the * with the kind's own `def.defaults()`. The "Painting" type swaps in the
* material-paint panel. * material-paint panel.
*/ */
// MEP tool kinds that, when active, mean the MEP group tile (and its sub-grid)
// is what the user is working in.
const MEP_TOOL_KINDS = new Set<string>([
...MEP_ITEMS.map((item) => item.kind),
'duct-fitting',
'pipe-fitting',
])
export function BuildTab() { export function BuildTab() {
const activeTool = useEditor((s) => s.tool) const activeTool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow) const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
// Which build tile's panel is showing. Roof (Features) and MEP (its tool
// sub-grid) are the tiles with a panel; others arm a tool and show nothing
// below.
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null)
// The fitting / follow tools are armed from a segment's panel, not a grid // The fitting / follow tools are armed from a segment's panel, not a grid
// tile — keep the segment tile lit so the panel (and the way back) stays // tile — keep the segment tile lit so the panel (and the way back) stays
@@ -201,8 +205,23 @@ export function BuildTab() {
return features return features
}, []) }, [])
const isTypeActive = (type: BuildType) => // Tile highlight derives from the single source of truth (the active tool /
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id // mode), never a separate local selection — so keyboard shortcuts and panel
// clicks always agree on which tile is lit.
// The roof Features sub-grid arms roof-accessory tools (skylight, chimney,
// …); keep the Roof tile lit (and its panel open) while any of them is the
// active tool, the same way MEP stays lit for its sub-grid tools.
const isRoofFeatureActive =
mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool)
const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)
const isTypeActive = (type: BuildType) => {
if (type.mode === 'material-paint') return mode === 'material-paint'
if (type.id === 'mep') return isMepActive
if (type.id === 'roof')
return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive)
return mode === 'build' && activeTool === type.kind
}
const handleTypeClick = useCallback((type: BuildType) => { const handleTypeClick = useCallback((type: BuildType) => {
if (type.mode === 'material-paint') { if (type.mode === 'material-paint') {
@@ -214,15 +233,18 @@ export function BuildTab() {
} else if (type.kind) { } else if (type.kind) {
activateBuildTool(type.kind) activateBuildTool(type.kind)
} }
setSelectedTypeId(type.id)
}, []) }, [])
// On open, land on the first build tool — parity with the community Build // On open, land on the first build tool — parity with the community Build
// sidebar, so switching to Build immediately arms a usable tool. // sidebar, so switching to Build immediately arms a usable tool. Skip when a
// build tool is already active (e.g. the B shortcut armed one before this
// panel mounted): the active tool is the source of truth, not this default.
const didInitRef = useRef(false) const didInitRef = useRef(false)
useEffect(() => { useEffect(() => {
if (didInitRef.current) return if (didInitRef.current) return
didInitRef.current = true didInitRef.current = true
const ed = useEditor.getState()
if (ed.mode === 'build' && ed.tool) return
const firstType = BUILD_TYPES.find((t) => t.kind) const firstType = BUILD_TYPES.find((t) => t.kind)
if (firstType) handleTypeClick(firstType) if (firstType) handleTypeClick(firstType)
}, [handleTypeClick]) }, [handleTypeClick])
@@ -275,7 +297,9 @@ export function BuildTab() {
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
<MaterialPaintPanel /> <MaterialPaintPanel />
</div> </div>
) : selectedTypeId === 'roof' && roofFeatures.length > 0 ? ( ) : mode === 'build' &&
(activeTool === 'roof' || isRoofFeatureActive) &&
roofFeatures.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div> <div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
<TooltipProvider delayDuration={0} disableHoverableContent> <TooltipProvider delayDuration={0} disableHoverableContent>
@@ -320,7 +344,7 @@ export function BuildTab() {
</div> </div>
</TooltipProvider> </TooltipProvider>
</div> </div>
) : selectedTypeId === 'mep' ? ( ) : isMepActive ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div> <div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div>
<TooltipProvider delayDuration={0} disableHoverableContent> <TooltipProvider delayDuration={0} disableHoverableContent>
@@ -1,8 +1,10 @@
import { nodeRegistry } from '../../registry'
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve' import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve'
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { SpatialGrid } from './spatial-grid' import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid'
@@ -54,6 +56,29 @@ function getItemFootprint(
] ]
} }
/**
* Axis-aligned XZ extent of a footprint at `position`, rotated by `yRot`. The
* rotated width/depth is the same conservative bound the floor-placement draft
* uses, so a draft and an existing node are compared with identical math.
*/
function footprintBoundsXZ(
position: [number, number, number],
dimensions: [number, number, number],
yRot: number,
): { minX: number; maxX: number; minZ: number; maxZ: number } {
const [width, , depth] = dimensions
const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot))
const rotatedW = width * cos + depth * sin
const rotatedD = width * sin + depth * cos
return {
minX: position[0] - rotatedW / 2,
maxX: position[0] + rotatedW / 2,
minZ: position[2] - rotatedD / 2,
maxZ: position[2] + rotatedD / 2,
}
}
type ItemLocalBounds = { type ItemLocalBounds = {
min: [number, number, number] min: [number, number, number]
max: [number, number, number] max: [number, number, number]
@@ -647,34 +672,38 @@ export class SpatialGridManager {
) { ) {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const ignoreSet = new Set(ignoreIds ?? []) const ignoreSet = new Set(ignoreIds ?? [])
const [width, , depth] = dimensions const draftBounds = footprintBoundsXZ(position, dimensions, rotation[1])
const yRot = rotation[1]
const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot))
const rotatedW = width * cos + depth * sin
const rotatedD = width * sin + depth * cos
const draftBounds = {
minX: position[0] - rotatedW / 2,
maxX: position[0] + rotatedW / 2,
minZ: position[2] - rotatedD / 2,
maxZ: position[2] + rotatedD / 2,
}
// A floor placement conflicts with any other COLLIDING floor-resting node,
// not just items — every kind whose `floorPlaced.collides` is set (item /
// shelf / column) contributes its footprint(s) as an obstacle. Each
// candidate's XZ extent is read from the same declarative footprint the
// elevation + sync paths use, so adding a colliding kind needs no change here.
const conflicts: string[] = [] const conflicts: string[] = []
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
if (node.type !== 'item') continue if (ignoreSet.has(node.id)) continue
const item = node as ItemNode const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (item.asset.attachTo) continue if (!floorPlaced?.collides) continue
if (isLowProfileItemSurface(item)) continue if (floorPlaced.applies && !floorPlaced.applies(node)) continue
if (ignoreSet.has(item.id)) continue // Low-profile item surfaces (rugs, mats) are stack-on targets, not
if (resolveNodeLevelId(item, nodes) !== levelId) continue // obstacles — keep the long-standing item-only exemption.
if (node.type === 'item' && isLowProfileItemSurface(node as ItemNode)) continue
if (resolveNodeLevelId(node, nodes) !== levelId) continue
const bounds = getItemParentAabb(item) for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
const fpRotation = Array.isArray(footprint.rotation) ? (footprint.rotation[1] ?? 0) : 0
const bounds = footprintBoundsXZ(
footprint.position ?? (node as { position: [number, number, number] }).position,
footprint.dimensions,
fpRotation,
)
if ( if (
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)
) { ) {
conflicts.push(item.id) conflicts.push(node.id)
break
}
} }
} }
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode } from '../schema' import { CeilingNode, SlabNode, WallNode } from '../schema'
import { planAutoCeilingsForLevel } from './space-detection' import { planAutoCeilingsForLevel, planAutoSlabsForLevel } from './space-detection'
const square: Array<[number, number]> = [ const square: Array<[number, number]> = [
[0, 0], [0, 0],
@@ -90,3 +90,26 @@ describe('planAutoCeilingsForLevel', () => {
expect(plan.update).toHaveLength(0) expect(plan.update).toHaveLength(0)
}) })
}) })
describe('planAutoSlabsForLevel', () => {
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
// Two rooms with identical polygon signatures previously collided in a
// signature-keyed Map, so one detected room never matched an existing slab
// and churned (delete + recreate) on every pass.
const slabA = slab(0.05)
const slabB = slab(0.05)
const plan = planAutoSlabsForLevel([roomPolygon(), roomPolygon()], [slabA, slabB])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
expect(plan.update).toHaveLength(0)
})
test('deletes an extra auto-slab when only one identical room is detected', () => {
const plan = planAutoSlabsForLevel([roomPolygon()], [slab(0.05), slab(0.05)])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(1)
})
})
+22 -36
View File
@@ -595,43 +595,25 @@ function levelWallSnapshot(walls: WallNode[]) {
return walls.map(wallGeometrySignature).sort().join('||') return walls.map(wallGeometrySignature).sort().join('||')
} }
function slabGeometrySignature(slab: SlabNodeType) { // Trigger signature is wall-only on purpose: re-detection should fire on a
const polygon = slab.polygon // genuine remodel (wall geometry change), never when an auto-slab is edited or
.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`) // deleted. Hashing slabs here created a feedback loop where deleting an
.join(';') // auto-slab re-fired detection and recreated it.
const holes = (slab.holes ?? [])
.map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';'))
.join('/')
return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join(
'|',
)
}
function levelSlabSnapshot(slabs: SlabNodeType[]) {
return slabs.map(slabGeometrySignature).sort().join('||')
}
function levelStructureSnapshots(nodes: Record<string, any>) { function levelStructureSnapshots(nodes: Record<string, any>) {
const byLevel = new Map<string, { walls: WallNode[]; slabs: SlabNodeType[] }>() const byLevel = new Map<string, WallNode[]>()
const getEntry = (levelId: string) => {
const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] }
byLevel.set(levelId, entry)
return entry
}
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
if ((node as any).type === 'wall') { if ((node as any).type !== 'wall') continue
getEntry((node as any).parentId).walls.push(node as WallNode) const levelId = (node as any).parentId as string
} else if ((node as any).type === 'slab') { const walls = byLevel.get(levelId) ?? []
getEntry((node as any).parentId).slabs.push(SlabNode.parse(node)) walls.push(node as WallNode)
} byLevel.set(levelId, walls)
} }
const snapshots = new Map<string, string>() const snapshots = new Map<string, string>()
for (const [levelId, entry] of byLevel.entries()) { for (const [levelId, walls] of byLevel.entries()) {
snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`) snapshots.set(levelId, levelWallSnapshot(walls))
} }
return snapshots return snapshots
@@ -692,13 +674,15 @@ export function planAutoSlabsForLevel(
const matchedDetectedIdx = new Set<number>() const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, [number, number][]>() const updatesById = new Map<string, [number, number][]>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>() const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) { for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry) const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
} }
detected.forEach((room, index) => { detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig) const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return if (!existing) return
matchedDetectedIdx.add(index) matchedDetectedIdx.add(index)
@@ -875,13 +859,15 @@ export function planAutoCeilingsForLevel(
const matchedDetectedIdx = new Set<number>() const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>() const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>() const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) { for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry) const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
} }
detected.forEach((room, index) => { detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig) const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return if (!existing) return
matchedDetectedIdx.add(index) matchedDetectedIdx.add(index)
+2
View File
@@ -31,6 +31,7 @@ export {
nodeRegistry, nodeRegistry,
type PluginDiscovery, type PluginDiscovery,
registerNode, registerNode,
resolveFacingIndicator,
setPluginDiscovery, setPluginDiscovery,
} from './registry' } from './registry'
export { export {
@@ -109,6 +110,7 @@ export type {
SelectableConfig, SelectableConfig,
SlotDeclaration, SlotDeclaration,
SnapPointKind, SnapPointKind,
SnapProfile,
SnappableConfig, SnappableConfig,
SnapServicesLike, SnapServicesLike,
SurfacePoint, SurfacePoint,
+12
View File
@@ -178,6 +178,18 @@ export function isPresettableKind(kind: string): boolean {
return def ? isPresettable(def) : false return def ? isPresettable(def) : false
} }
/**
* Resolve a kind's facing-triangle config, or `null` when it has none.
* `{ reversed }` says whether the triangle points along the node's local -Z
* (its front) instead of +Z. One reader (the editor-side `<FacingPoseIndicator>`
* publishers) so placement and move stay consistent.
*/
export function resolveFacingIndicator(kind: string): { reversed: boolean } | null {
const facing = nodeRegistry.get(kind)?.facingIndicator
if (!facing) return null
return { reversed: facing === true ? false : (facing.reversed ?? false) }
}
/** /**
* Names of schema fields on `def` that are host references (`wallId`, * Names of schema fields on `def` that are host references (`wallId`,
* `wallT`, etc.). Read by host apps at preset-save time to strip these * `wallT`, etc.). Read by host apps at preset-save time to strip these
+94 -13
View File
@@ -15,9 +15,8 @@ import type { CloneNodesIntoOptions, Subtree } from './subtree'
// door cutouts read parent wall — use `ctx` to resolve those references // door cutouts read parent wall — use `ctx` to resolve those references
// without importing `useScene`. Builders stay pure and unit-testable. // without importing `useScene`. Builders stay pure and unit-testable.
// //
// Future extension: `levelData?: { miters?: ... }` for level-scoped batch // `levelData` carries level-scoped batch data (wall mitering across an
// data (wall mitering across an entire level). Decided alongside the wall // entire level) from registry dispatchers into pure builders.
// migration off its dedicated system (Phase 3+).
export type GeometryContext = { export type GeometryContext = {
/** Look up any node by ID. Returns undefined if the node doesn't exist. */ /** Look up any node by ID. Returns undefined if the node doesn't exist. */
@@ -30,18 +29,16 @@ export type GeometryContext = {
parent: AnyNode | null parent: AnyNode | null
/** /**
* Pre-computed level-batch data, populated by the dispatcher when the * Pre-computed level-batch data, populated by the dispatcher when the
* kind declares `def.computeLevelData`. Shared across every * kind declares `def.computeLevelData` (3D) or
* `def.geometry(node, ctx)` call in the same level batch within a * `def.computeFloorplanLevelData` (2D). Shared across every builder call
* single frame, so kinds whose geometry depends on cross-sibling * in the same level batch within a single frame/render pass, so kinds
* data (wall mitering, gradient sky uniforms across a zone, etc.) * whose geometry depends on cross-sibling data (wall mitering, gradient
* don't pay an O(N²) recomputation cost. * sky uniforms across a zone, etc.) don't pay an O(N²) recomputation cost.
* *
* Typed as `unknown` at the framework boundary — kinds cast to their * Typed as `unknown` at the framework boundary — kinds cast to their
* own `LevelData` shape inside `def.geometry` (the same kind owns * own `LevelData` shape inside `def.geometry` / `def.floorplan` (the
* both the `computeLevelData` return shape and the `geometry` * same kind owns both the compute hook's return shape and the builder
* consumer, so the cast is internal). Only populated for `def. * consumer, so the cast is internal).
* geometry` calls today; not used by `def.floorplan` (which already
* has cheap access to siblings through `ctx.siblings`).
*/ */
levelData?: unknown levelData?: unknown
/** /**
@@ -224,6 +221,13 @@ export type ToolHint = {
key: string key: string
/** Description of what the input does. Sentence case. */ /** Description of what the input does. Sentence case. */
label: string label: string
/**
* Only show this hint once the in-progress draft has at least this many
* vertices (reads `useEditor.draftVertexCount`). Lets a polygon tool's
* "Finish" hint appear only when finishing is actually possible (≥ 3 points),
* so the HUD reflects reality. Omit for always-shown hints.
*/
minDraftVertices?: number
} }
export type FloorplanGeometry = export type FloorplanGeometry =
@@ -713,12 +717,29 @@ export type SurfaceRole =
/** Role a kind plays in a duct / pipe / lineset distribution system. */ /** Role a kind plays in a duct / pipe / lineset distribution system. */
export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment' export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment'
/**
* A kind's snapping profile (see `NodeDefinition.snapProfile`).
* - `'item'` free object (furniture/fixtures): lines-default, no grid lattice, no angle.
* - `'structural'` walls / fences / slabs / ceilings / roofs / zones: grid-default, and an
* angle lock while *setting direction* (drafting a run/polygon, dragging an endpoint or a
* polygon vertex). A plain translate or a curve of a structural node has no angle.
*/
export type SnapProfile = 'item' | 'structural'
export type NodeDefinition<S extends ZodObject<any>> = { export type NodeDefinition<S extends ZodObject<any>> = {
kind: string kind: string
schemaVersion: number schemaVersion: number
schema: S schema: S
category: NodeCategory category: NodeCategory
surfaceRole?: SurfaceRole surfaceRole?: SurfaceRole
/**
* Show a floor direction-triangle while placing/moving — the kind has a
* meaningful front. `true` points along the node's local +Z (forward).
* `{ reversed: true }` points along local -Z, for kinds whose front is the
* -Z side (a stair faces *out* of its run: you approach from the low end,
* which sits on the -Z side of the footprint).
*/
facingIndicator?: boolean | { reversed?: boolean }
/** /**
* Role this kind plays in a distribution system (HVAC duct / DWV pipe / * Role this kind plays in a distribution system (HVAC duct / DWV pipe /
* refrigerant lineset). Lets the system-graph summary classify a * refrigerant lineset). Lets the system-graph summary classify a
@@ -820,6 +841,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* runs once even when many walls are dirty in the same frame. * runs once even when many walls are dirty in the same frame.
*/ */
computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown
/**
* Floor-plan level-batch precompute hook. The floor-plan layer calls this
* once per level per render pass, de-duplicated by kind, before the
* per-node `def.floorplan` calls. The result lands in `ctx.levelData` for
* every node of this kind in the level.
*
* Used to hoist cross-sibling floor-plan work that would otherwise be
* O(N²) when rebuilding every node in a kind — e.g. wall mitering. `nodes`
* is the live-merged scene snapshot; `siblings` is every node of this kind
* in the level, also live-merged.
*/
computeFloorplanLevelData?: (args: {
siblings: ReadonlyArray<z.infer<S>>
nodes: Record<string, AnyNode>
}) => unknown
/** /**
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits * Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js * plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
@@ -877,6 +913,12 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* unset and rely on the generic overlay path. * unset and rely on the generic overlay path.
*/ */
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>> floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
/**
* Geometry reads sibling/parent/child nodes (e.g. wall miters, opening
* dimensions); the floor-plan layer must rebuild it whenever a
* sibling-affecting node is being dragged live.
*/
floorplanDependsOnSiblings?: boolean
/** /**
* Optional hook letting a kind project the `useLiveNodeOverrides` map * Optional hook letting a kind project the `useLiveNodeOverrides` map
* into a fresh `nodes` snapshot before its `def.floorplan` builder * into a fresh `nodes` snapshot before its `def.floorplan` builder
@@ -940,6 +982,29 @@ export type NodeDefinition<S extends ZodObject<any>> = {
*/ */
toolHints?: ToolHint[] toolHints?: ToolHint[]
/**
* Which snapping profile this kind uses, so the editor's contextual snapping
* HUD + snap math + force-place affordance are node-declared rather than
* switched on the kind name (`'item'` free object vs `'structural'` wall/slab/
* surface — see `SnapProfile`). The angle lock is derived from the *action*
* (setting direction), not declared here. Also gates the "force place" hint:
* structural kinds don't collision-reject, so they don't show it.
* Omit it for kinds whose placement/move tools haven't moved onto the unified
* snapping model yet — they get no snapping chip (no Shift-cycle) until they do.
*/
snapProfile?: SnapProfile
/**
* For `structural` kinds: does drafting this kind set a DIRECTION (so the
* angle-lock snapping mode is meaningful)? Wall/fence/slab/ceiling drafting
* draws directed edges → `true` (the default). Roof/stair/elevator are placed
* as axis-aligned footprints, not directional draws → `false`, so their
* drafting uses the no-angle `polygon` snap context (grid / lines / off)
* instead of the angle-bearing `wall` context. Ignored for `item` kinds
* (their context never carries an angle lock).
*/
snapDraftDirectional?: boolean
/** /**
* Optional translucent preview of the node — used by the move tool to * Optional translucent preview of the node — used by the move tool to
* show where the node will land, and by the placement tool's cursor. * show where the node will land, and by the placement tool's cursor.
@@ -1249,6 +1314,13 @@ export type SlotDeclaration = {
} }
export type PaintCapability = { export type PaintCapability = {
/**
* Opt this kind into the painter's `room` application scope: a paint click
* spreads to every same-kind node bounding the clicked node's room (walls and
* slabs). The room geometry is resolved by the editor from `Space.polygon`;
* this flag only declares that the kind participates.
*/
roomScope?: boolean
/** /**
* Resolve which logical surface the user clicked. Returns `null` * Resolve which logical surface the user clicked. Returns `null`
* when the face shouldn't be painted (e.g. interior slot exposed * when the face shouldn't be painted (e.g. interior slot exposed
@@ -1522,6 +1594,15 @@ export type FloorPlacedConfig = {
footprint?: FloorPlacedFootprintResolver footprint?: FloorPlacedFootprintResolver
footprints?: FloorPlacedFootprintsResolver footprints?: FloorPlacedFootprintsResolver
applies?: (node: AnyNode) => boolean applies?: (node: AnyNode) => boolean
/**
* Opt this kind into floor-placement collision: its footprint blocks other
* placements (it's an obstacle in `canPlaceOnFloor`) AND its own
* placement/move refuses to overlap another colliding footprint (red ghost,
* Alt to force). Solid furniture-like kinds (item / shelf / column) set this;
* markers and port-mated kinds (spawn / MEP / stair) leave it off so they
* neither block nor get blocked. Default off.
*/
collides?: boolean
} }
/** /**
@@ -5,6 +5,7 @@ import type { AnyNodeDefinition, Capabilities, SceneApi } from '../registry/type
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { import {
canAttach, canAttach,
canHostOnTop,
clampYToHostTop, clampYToHostTop,
getSurface, getSurface,
getTopSurfaceHeight, getTopSurfaceHeight,
@@ -14,6 +15,16 @@ import {
const id = (s: string) => s as AnyNodeId const id = (s: string) => s as AnyNodeId
function makeItem(idStr: string, attachTo?: 'wall' | 'wall-side' | 'ceiling'): AnyNode {
return {
id: id(idStr),
type: 'item',
parentId: null,
visible: true,
asset: attachTo ? { attachTo } : {},
} as unknown as AnyNode
}
function makeDef( function makeDef(
kind: string, kind: string,
capabilities: Capabilities = {}, capabilities: Capabilities = {},
@@ -233,4 +244,30 @@ describe('pickHost', () => {
}) })
expect(picked?.id).toBe(id('s2')) expect(picked?.id).toBe(id('s2'))
}) })
test('excludes ceiling-mounted hosts (ceiling fan cannot be a top surface)', () => {
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
const candidates = [makeItem('fan', 'ceiling'), makeItem('table')]
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
expect(picked?.id).toBe(id('table'))
})
test('keeps wall-mounted hosts (wall shelf still hosts)', () => {
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
const candidates = [makeItem('shelf', 'wall')]
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
expect(picked?.id).toBe(id('shelf'))
})
})
describe('canHostOnTop', () => {
test('rejects ceiling-attachTo hosts', () => {
expect(canHostOnTop(makeItem('fan', 'ceiling'))).toBe(false)
})
test('accepts wall / wall-side / floor (undefined) hosts', () => {
expect(canHostOnTop(makeItem('shelf', 'wall'))).toBe(true)
expect(canHostOnTop(makeItem('sconce', 'wall-side'))).toBe(true)
expect(canHostOnTop(makeItem('table'))).toBe(true)
})
}) })
+13 -4
View File
@@ -112,6 +112,18 @@ export function getTopSurfaceHeight(host: AnyNode): number | null {
return typeof height === 'function' ? height(host) : height return typeof height === 'function' ? height(host) : height
} }
/**
* Whether `host` can receive a surface-resting (top-stacked) child. A
* ceiling-mounted item hangs from the ceiling, so its visible "top" is not a
* usable resting surface — nothing should stack on a ceiling fan. The check
* reads the instance-level `asset.attachTo` (not the host KIND, which is shared
* across all items) so a single gate covers every interaction path.
*/
export function canHostOnTop(host: AnyNode): boolean {
const attachTo = (host as { asset?: { attachTo?: string } }).asset?.attachTo
return attachTo !== 'ceiling'
}
/** /**
* Pure host-discovery helper. Given a list of candidate hosts (already * Pure host-discovery helper. Given a list of candidate hosts (already
* narrowed by spatial query) and a point, returns the first whose * narrowed by spatial query) and a point, returns the first whose
@@ -129,10 +141,7 @@ export function pickHost(args: {
const def = nodeRegistry.get(host.type) const def = nodeRegistry.get(host.type)
const hostable = def?.capabilities.hostable const hostable = def?.capabilities.hostable
if (!hostable) continue if (!hostable) continue
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) { if (!canHostOnTop(host)) continue
// capability declares specific parents; verify the placed kind's own def
// also permits this host kind.
}
if (args.hitTest && !args.hitTest(host, args.point)) continue if (args.hitTest && !args.hitTest(host, args.point)) continue
return host return host
} }
+1
View File
@@ -34,6 +34,7 @@ export {
type AttachError, type AttachError,
type AttachResult, type AttachResult,
canAttach, canAttach,
canHostOnTop,
clampYToHostTop, clampYToHostTop,
getSurface, getSurface,
getTopSurfaceHeight, getTopSurfaceHeight,
@@ -2,6 +2,11 @@
import { memo, type MouseEvent as ReactMouseEvent } from 'react' import { memo, type MouseEvent as ReactMouseEvent } from 'react'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import {
useEndpointReshape,
useIsCurveReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu' import { NodeActionMenu } from '../editor/node-action-menu'
type SvgPoint = { type SvgPoint = {
@@ -48,12 +53,11 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
offsetY = 10, offsetY = 10,
}: FloorplanActionMenuLayerProps) { }: FloorplanActionMenuLayerProps) {
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useMovingNode()
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const endpointReshape = useEndpointReshape()
const curvingWall = useEditor((state) => state.curvingWall) const isCurveReshape = useIsCurveReshape()
const curvingFence = useEditor((state) => state.curvingFence)
if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) { if (!isFloorplanHovered || movingNode || endpointReshape || isCurveReshape) {
return null return null
} }
@@ -16,6 +16,7 @@ import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu' import { NodeActionMenu } from '../editor/node-action-menu'
/** /**
@@ -46,8 +47,9 @@ import { NodeActionMenu } from '../editor/node-action-menu'
*/ */
export function FloorplanRegistryActionMenu() { export function FloorplanRegistryActionMenu() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const movingNode = useEditor((s) => s.movingNode) const movingNode = useMovingNode()
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Gate on floorplan hover so this 2D menu never coexists with the 3D // Gate on floorplan hover so this 2D menu never coexists with the 3D
// FloatingActionMenu in split view — that menu hides while the floorplan // FloatingActionMenu in split view — that menu hides while the floorplan
// is hovered, so this one must only show then. Mirrors the legacy // is hovered, so this one must only show then. Mirrors the legacy
@@ -141,6 +143,11 @@ export function FloorplanRegistryActionMenu() {
const handleMove = () => { const handleMove = () => {
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never) setMovingNode(node as never)
// 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
// Mark the origin (after `setMovingNode`, which resets it to null) so
// `ToolManager` keeps the 3D affordance mover from also adopting the node
// and reverting it on unmount. Mirrors the orange move-dot path.
setMovingNodeOrigin('2d')
// Match the legacy 3D `floating-action-menu`: clear selection so // Match the legacy 3D `floating-action-menu`: clear selection so
// selection-gated affordances unmount during the drag. Specifically // selection-gated affordances unmount during the drag. Specifically
// the slab / ceiling boundary editor (`ToolManager` shows it when // the slab / ceiling boundary editor (`ToolManager` shows it when
@@ -23,7 +23,8 @@ import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useAlignmentGuides from '../../store/use-alignment-guides' import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor from '../../store/use-editor' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts' import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
// Figma-style alignment snap threshold. Meters in world space; 8cm gives // Figma-style alignment snap threshold. Meters in world space; 8cm gives
@@ -53,7 +54,7 @@ const ALIGNMENT_THRESHOLD_M = 0.08
* cursor → meters accounts for pan / zoom / building rotation. * cursor → meters accounts for pan / zoom / building rotation.
*/ */
export function FloorplanRegistryMoveOverlay() { export function FloorplanRegistryMoveOverlay() {
const movingNode = useEditor((s) => s.movingNode) const movingNode = useMovingNode()
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
@@ -508,10 +509,12 @@ export function FloorplanRegistryMoveOverlay() {
if (!m) return if (!m) return
// 1) Grid snap baseline. Fresh catalog placement is absolute under // 1) Grid snap baseline. Fresh catalog placement is absolute under
// the cursor; existing moves preserve the cursor's grab offset. // the cursor; existing moves preserve the cursor's grab offset. Grid
// follows the active snapping mode (Shift cycles it); raw cursor in
// any non-grid mode.
const gridStep = useEditor.getState().gridSnapStep const gridStep = useEditor.getState().gridSnapStep
const snap = (value: number) => const snap = (value: number) =>
event.shiftKey ? value : Math.round(value / gridStep) * gridStep isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
const resolved = resolvePlanarCursorPosition({ const resolved = resolvePlanarCursorPosition({
cursor: [m[0], m[1]], cursor: [m[0], m[1]],
original: [originalPosition[0], originalPosition[2]], original: [originalPosition[0], originalPosition[2]],
@@ -524,12 +527,12 @@ export function FloorplanRegistryMoveOverlay() {
// 2) Alignment snap layered on top. Treat the grid-snapped point // 2) Alignment snap layered on top. Treat the grid-snapped point
// as the "proposed" position so alignment competes from a stable // as the "proposed" position so alignment competes from a stable
// base rather than the raw cursor jitter. Alt bypasses alignment // base rather than the raw cursor jitter. Alignment ("lines") follows
// entirely; Shift bypasses both grid and alignment // the magnetic snapping mode — independent of grid; Alt is force-place,
// hint chip. // not a snap bypass.
let finalX = gridX let finalX = gridX
let finalZ = gridZ let finalZ = gridZ
if (!(event.altKey || event.shiftKey) && candidateAnchors.length > 0) { if (isMagneticSnapActive() && candidateAnchors.length > 0) {
// Translate the cached local bbox to the proposed pos to get the // Translate the cached local bbox to the proposed pos to get the
// moving anchors at that location. The entry's untransformed // moving anchors at that location. The entry's untransformed
// bbox is in world meters relative to the node's origin, so a // bbox is in world meters relative to the node's origin, so a
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,11 @@ import {
} from 'three' } from 'three'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import {
useActiveHandleDrag,
useEndpointReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
const currentTarget = new Vector3() const currentTarget = new Vector3()
const tempBox = new Box3() const tempBox = new Box3()
@@ -611,18 +616,12 @@ export const CustomCameraControls = () => {
const tool = useEditor((s) => s.tool) const tool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const selectionTool = useEditor((s) => s.floorplanSelectionTool) const selectionTool = useEditor((s) => s.floorplanSelectionTool)
const movingNode = useEditor((s) => s.movingNode) const movingNode = useMovingNode()
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) const endpointReshape = useEndpointReshape()
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) const activeHandleDrag = useActiveHandleDrag()
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee' const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
const isInteracting = Boolean( const isInteracting = Boolean(
tool || tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive,
movingNode ||
movingWallEndpoint ||
movingFenceEndpoint ||
activeHandleDrag ||
isBoxSelectActive,
) )
const touches = useMemo(() => { const touches = useMemo(() => {
const twoFingerAction = const twoFingerAction =
@@ -1154,12 +1153,12 @@ export const CustomCameraControls = () => {
}, []) }, [])
// Preset capture mode frames a single subtree (often a 0.32m preset), // Preset capture mode frames a single subtree (often a 0.32m preset),
// so the default 6m minDistance prevents the user from getting close // so the default 2m minDistance prevents the user from getting close
// enough to compose a good thumbnail. Relax the clamp to 0.5m while // enough to compose a good thumbnail. Relax the clamp to 0.5m while
// capturing presets; reset on exit so general editing keeps the looser // capturing presets; reset on exit so general editing keeps the looser
// navigation guardrails. // navigation guardrails.
const isPresetCapture = captureMode.mode === 'preset' const isPresetCapture = captureMode.mode === 'preset'
const minDistance = isPresetCapture ? 0.5 : 6 const minDistance = isPresetCapture ? 0.5 : 2
if (isFirstPersonMode) { if (isFirstPersonMode) {
return null return null
@@ -8,6 +8,7 @@ import {
DEFAULT_WALL_HEIGHT, DEFAULT_WALL_HEIGHT,
DoorNode, DoorNode,
ElevatorNode, ElevatorNode,
emitter,
FenceNode, FenceNode,
generateId, generateId,
getActiveRoofHeight, getActiveRoofHeight,
@@ -35,10 +36,17 @@ import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useCallback, useMemo, useRef } from 'react' import { useCallback, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication' import { duplicateStairSubtree } from '../../lib/stair-duplication'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import useInteractionScope, {
useActiveHandleDrag,
useEndpointReshape,
useIsCurveReshape,
} from '../../store/use-interaction-scope'
import { formatMeasurement, MeasurementPill } from './measurement-pill' import { formatMeasurement, MeasurementPill } from './measurement-pill'
import { NodeActionMenu } from './node-action-menu' import { NodeActionMenu } from './node-action-menu'
@@ -137,6 +145,11 @@ function getAttributeVersion(
: 0 : 0
} }
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
// dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
const _anchorBox = new THREE.Box3()
const _anchorCenter = new THREE.Vector3()
function getObjectGeometryKey(object: THREE.Object3D): string { function getObjectGeometryKey(object: THREE.Object3D): string {
const parts: string[] = [] const parts: string[] = []
object.traverse((child) => { object.traverse((child) => {
@@ -203,21 +216,22 @@ export function FloatingActionMenu() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) const canFindNode = useEditor((s) => s.canFindNode)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) const endpointReshape = useEndpointReshape()
const curvingFence = useEditor((s) => s.curvingFence) const isCurveReshape = useIsCurveReshape()
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole)
const unit = useViewer((s) => s.unit) const unit = useViewer((s) => s.unit)
// Drives the height-drag dimension pill below the menu. `activeHandleDrag` // Drives the height-drag dimension pill below the menu. `activeHandleDrag`
// flips only at drag start / end, so subscribing here is cheap — the live // flips only at drag start / end, so subscribing here is cheap — the live
// height value is written imperatively in the useFrame below. // height value is written imperatively in the useFrame below.
const activeHandleDrag = useEditor((s) => s.activeHandleDrag) const activeHandleDrag = useActiveHandleDrag()
// R/T rotation axis for kinds with full 3D orientation (duct fittings). // R/T rotation axis for kinds with full 3D orientation (duct fittings).
const rotationAxis = useEditor((s) => s.rotationAxis) const rotationAxis = useEditor((s) => s.rotationAxis)
// The floating action menu is an action-conflicting control: hard-hidden
// during any active interaction so it never competes with the live action.
const scope = useInteractionScope((s) => s.scope)
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
const groupRef = useRef<THREE.Group>(null) const groupRef = useRef<THREE.Group>(null)
const menuScaleRef = useRef<HTMLDivElement>(null) const menuScaleRef = useRef<HTMLDivElement>(null)
@@ -329,23 +343,44 @@ export function FloatingActionMenu() {
// mid-resize). A spinning child changes the head's matrix, not the // mid-resize). A spinning child changes the head's matrix, not the
// registered group's, so it never triggers a recompute → the menu // registered group's, so it never triggers a recompute → the menu
// holds still. // holds still.
// Cheapest guards first: a selection swap, the object's own world
// transform changing (true every frame during a drag), a live override,
// or an active handle drag all force a recompute on their own — so skip
// the geometry traversal (`getObjectGeometryKey` walks the whole subtree
// reading attribute versions) until none of them fired and a
// geometry-only change is the only thing left that could move the anchor.
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
const dragActive = activeHandleDrag?.nodeId === selectedId const dragActive = activeHandleDrag?.nodeId === selectedId
const effectiveNode = getEffectiveNode(node)
const geometryKey = getObjectGeometryKey(obj)
const selectionChanged = const selectionChanged =
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld) const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
const geometryChanged = lastAnchorKeyRef.current.geometryKey !== geometryKey
if (selectionChanged || matrixChanged || geometryChanged || overrideActive || dragActive) { let geometryKey = lastAnchorKeyRef.current.geometryKey
let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive
// Only when nothing cheaper fired do we pay for the subtree traversal —
// a geometry-only change is the lone remaining trigger. When a cheaper
// guard already forced a recompute the stored key is reused; the matrix
// (or override/drag) keeps recomputing the anchor every frame, so a
// geometry edit mid-drag is absorbed, and the next idle frame refreshes
// the key against the live geometry.
if (!needsRecompute) {
geometryKey = getObjectGeometryKey(obj)
if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true
}
if (needsRecompute) {
const effectiveNode = getEffectiveNode(node)
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) { if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
const box = new THREE.Box3().setFromObject(obj) _anchorBox.setFromObject(obj)
if (!box.isEmpty()) { if (!_anchorBox.isEmpty()) {
const center = box.getCenter(new THREE.Vector3()) _anchorBox.getCenter(_anchorCenter)
// Position above the object. Per-type offsets clear each kind's // Position above the object. Per-type offsets clear each kind's
// in-world chrome (height-resize arrows, measurement labels). // in-world chrome (height-resize arrows, measurement labels).
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(effectiveNode), center.z) anchorRef.current.set(
_anchorCenter.x,
_anchorBox.max.y + getMenuYOffset(effectiveNode),
_anchorCenter.z,
)
hasAnchorRef.current = true hasAnchorRef.current = true
} }
} else { } else {
@@ -368,15 +403,15 @@ export function FloatingActionMenu() {
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
if (node.type === 'wall') { if (node.type === 'wall') {
if (!canCurveSelectedWall) return if (!canCurveSelectedWall) return
setCurvingWall(node) useInteractionScope.getState().begin(curveReshapeScope(node.id))
} else if (node.type === 'fence') { } else if (node.type === 'fence') {
setCurvingFence(node) useInteractionScope.getState().begin(curveReshapeScope(node.id))
} else { } else {
return return
} }
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, },
[canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection], [canCurveSelectedWall, node, setSelection],
) )
const handleMove = useCallback( const handleMove = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -602,11 +637,13 @@ export function FloatingActionMenu() {
holes: [...currentHoles, newHole], holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }], holeMetadata: [...currentMetadata, { source: 'manual' }],
}) })
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) useInteractionScope
.getState()
.begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
// Re-assert selection so the node stays selected // Re-assert selection so the node stays selected
setSelection({ selectedIds: [selectedId] }) setSelection({ selectedIds: [selectedId] })
}, },
[node, selectedId, updateNode, setEditingHole, setSelection], [node, selectedId, updateNode, setSelection],
) )
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -620,11 +657,21 @@ export function FloatingActionMenu() {
[node?.type, selectedId, setSelection], [node?.type, selectedId, setSelection],
) )
// "Find in catalog": the editor only signals intent — the host (community)
// listens for `selection:find-node` and reveals the node in its browser.
const handleFind = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (node) emitter.emit('selection:find-node' as never, node as never)
},
[node],
)
if ( if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint || endpointReshape ||
movingFenceEndpoint || isCurveReshape ||
curvingFence menuStepBack
) )
return null return null
@@ -641,6 +688,7 @@ export function FloatingActionMenu() {
> >
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}> <div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
<NodeActionMenu <NodeActionMenu
onFind={node && canFindNode ? handleFind : undefined}
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined} onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={ onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall) node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
File diff suppressed because it is too large Load Diff
+143 -35
View File
@@ -1,14 +1,28 @@
'use client' 'use client'
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer' import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { MathUtils, type Mesh, PlaneGeometry, Vector2 } from 'three' import { DoubleSide, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three'
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl' import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useCeilingEvents } from '../../hooks/use-ceiling-events' import { useCeilingEvents } from '../../hooks/use-ceiling-events'
import { useGridEvents } from '../../hooks/use-grid-events' import { useGridEvents } from '../../hooks/use-grid-events'
import { getPlacementSurface } from '../../lib/active-placement-surface'
import useEditor, { isGridSnapActive } from '../../store/use-editor'
import { getMovingNode } from '../../store/use-interaction-scope'
// Reveal radius (m) of the cursor-local grid patch shown while placing/moving in
// grid-snap mode — much tighter than the idle reveal so only the area you're
// about to snap into lights up.
const PLACEMENT_REVEAL_RADIUS = 12
const UP = new Vector3(0, 1, 0)
// PlaneGeometry faces +Z; this is the orientation that lays it flat (its normal
// → world +Y), equivalent to the old `rotation-x={-π/2}`.
const PLANE_LOCAL_NORMAL = new Vector3(0, 0, 1)
const HORIZONTAL_QUATERNION = new Quaternion().setFromUnitVectors(PLANE_LOCAL_NORMAL, UP)
export const Grid = ({ export const Grid = ({
cellSize = 0.5, cellSize = 0.5,
@@ -38,6 +52,28 @@ export const Grid = ({
const effectiveSectionColor = isDark ? '#666677' : sectionColor const effectiveSectionColor = isDark ? '#666677' : sectionColor
const cursorPositionRef = useRef(new Vector2(0, 0)) const cursorPositionRef = useRef(new Vector2(0, 0))
// Scratch for reading a moving node's world Y (surface elevation) each frame.
const worldPosRef = useRef(new Vector3())
// Scratch for the wall-anchored branch: invert the plane orientation to map the
// ghost into plane-local XY (the cursor reveal) without re-centring the mesh.
const invQuatRef = useRef(new Quaternion())
const wallCursorRef = useRef(new Vector3())
// Last Y pushed to `gridY` state, so the per-frame surface follow only triggers
// a React re-render when the height actually changes (not every frame).
const lastGridYRef = useRef<number | null>(null)
// Reveal radius + baseline alpha are uniforms so a placement/move can shrink
// the grid to a tight cursor patch (and drop the always-on baseline) without
// rebuilding the shader. Driven each frame in `useFrame`.
const revealRadiusUniform = useMemo(() => uniform(revealRadius), [revealRadius])
const baseAlphaUniform = useMemo(() => uniform(0.4), [])
// Created once and driven by `.value` each frame (see `useFrame`). Keying this
// on `cellSize` rebuilt the uniform AND the material `useMemo` below on every
// `gridSnapStep` change — a full shader recompile that stalled hard whenever
// the grid resolution changed. The live cell size is a uniform write only.
// biome-ignore lint/correctness/useExhaustiveDependencies: created once on purpose; `.value` is driven each frame.
const cellSizeUniform = useMemo(() => uniform(cellSize), [])
const patchAlphaUniform = useMemo(() => uniform(1), [])
const material = useMemo(() => { const material = useMemo(() => {
// Use xy since plane geometry is in XY space (before rotation) // Use xy since plane geometry is in XY space (before rotation)
@@ -48,7 +84,7 @@ export const Grid = ({
// Grid line function using fwidth for anti-aliasing // Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere // Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => { const getGrid = (size: number | typeof cellSizeUniform, thickness: number) => {
const r = pos.div(size) const r = pos.div(size)
const fw = fwidth(r) const fw = fwidth(r)
// Distance to nearest grid line for each axis // Distance to nearest grid line for each axis
@@ -70,7 +106,7 @@ export const Grid = ({
return lineX.max(lineY) return lineX.max(lineY)
} }
const g1 = getGrid(cellSize, cellThickness) const g1 = getGrid(cellSizeUniform, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness) const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center // Distance fade from center
@@ -79,7 +115,9 @@ export const Grid = ({
// Cursor reveal effect - distance from cursor // Cursor reveal effect - distance from cursor
const cursorDist = pos.sub(cursorPos).length() const cursorDist = pos.sub(cursorPos).length()
const cursorFade = float(1).sub(cursorDist.div(revealRadius).clamp(0, 1)).smoothstep(0, 1) const cursorFade = float(1)
.sub(cursorDist.div(revealRadiusUniform).clamp(0, 1))
.smoothstep(0, 1)
// Mix colors based on section grid // Mix colors based on section grid
const gridColor = mix( const gridColor = mix(
@@ -88,21 +126,26 @@ export const Grid = ({
float(sectionThickness).mul(g2).min(1), float(sectionThickness).mul(g2).min(1),
) )
// Baseline alpha: small amount of opacity everywhere the grid exists
const baseAlpha = float(0.4) // Subtle global visibility
// Combined alpha with cursor fade and baseline minimum // Combined alpha with cursor fade and baseline minimum
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha)) const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlphaUniform))
const finalAlpha = mix(alpha.mul(0.75), alpha, g2) const boostedAlpha = alpha.mul(patchAlphaUniform).min(1)
const finalAlpha = mix(boostedAlpha.mul(0.75), boostedAlpha, g2)
return new MeshBasicNodeMaterial({ return new MeshBasicNodeMaterial({
transparent: true, transparent: true,
colorNode: gridColor, colorNode: gridColor,
opacityNode: finalAlpha, opacityNode: finalAlpha,
depthWrite: false, depthWrite: false,
// `depthTest` is toggled per-frame in `useFrame`: ON for the floor lattice
// (so the ground occludes a sub-floor grid) and OFF on a wall (so the
// lattice shows through the wall when the opening is handled from the far
// side). Default ON for the floor case.
depthTest: true,
// Wall-plane placements are handled from either side of the wall, so the
// lattice must render from both faces.
side: DoubleSide,
}) })
}, [ }, [
cellSize,
cellThickness, cellThickness,
effectiveCellColor, effectiveCellColor,
sectionSize, sectionSize,
@@ -110,7 +153,10 @@ export const Grid = ({
effectiveSectionColor, effectiveSectionColor,
fadeDistance, fadeDistance,
fadeStrength, fadeStrength,
revealRadius, revealRadiusUniform,
baseAlphaUniform,
cellSizeUniform,
patchAlphaUniform,
]) ])
const gridRef = useRef<Mesh>(null!) const gridRef = useRef<Mesh>(null!)
@@ -124,13 +170,10 @@ export const Grid = ({
useCeilingEvents() useCeilingEvents()
// Track the last world-space cursor hit. The reveal-fade shader reads // Track the last world-space cursor hit. The reveal-fade shader reads
// `positionLocal.xy` (vertex position on the un-transformed plane), and // `positionLocal.xy` (vertex position on the un-transformed plane), and the
// the mesh's -π/2 X rotation maps `positionLocal.y` to world `-Z` // laid-flat orientation maps `positionLocal.y` to world `-Z` relative to the
// relative to the mesh origin. The mesh origin itself is lerped each // mesh origin. The cursor is recomputed every frame from the stored world hit
// frame toward the active building's world XZ (see `useFrame` below), // so the reveal stays put regardless of where the mesh origin sits.
// so the local-frame cursor must be recomputed every frame from the
// stored world cursor — otherwise the ring drifts whenever the grid is
// mid-lerp (e.g. just after a building rotation commits).
const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null) const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
useEffect(() => { useEffect(() => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
@@ -143,32 +186,96 @@ export const Grid = ({
} }
}, []) }, [])
useFrame((_, delta) => { useFrame(() => {
const { levelId } = useViewer.getState().selection const { levelId } = useViewer.getState().selection
// Grid stays anchored to world XZ (0, 0) — never chases the active let levelY = 0
// building. The Y origin still lerps to the active level so the grid
// sits at floor height when a level is open.
let targetY = 0
if (levelId) { if (levelId) {
const levelMesh = sceneRegistry.nodes.get(levelId) const levelMesh = sceneRegistry.nodes.get(levelId)
if (levelMesh) { if (levelMesh) {
targetY = levelMesh.position.y levelY = levelMesh.position.y
} }
} }
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
gridRef.current.position.y = newY
setGridY(newY)
// Grid XZ is fixed at world origin, so the local-frame cursor uniform // Resolve the surface the active ghost is snapped to (contact point +
// is just the world cursor (mirrored on Z to match the -π/2 X-rotation // normal). A fresh GLB item / drawn kind publishes via the surface module; a
// of the plane). // moving node is read straight off its mesh (treated as horizontal). Null
// when nothing is being placed.
const published = getPlacementSurface()
const movingForGrid = getMovingNode()
let surfacePoint: Vector3 | null = null
let surfaceNormal = UP
if (published) {
surfacePoint = published.point
surfaceNormal = published.normal
} else if (movingForGrid) {
const ghostMesh = sceneRegistry.nodes.get(movingForGrid.id as AnyNodeId)
if (ghostMesh) surfacePoint = ghostMesh.getWorldPosition(worldPosRef.current)
}
const gridMesh = gridRef.current
const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
if (onWall && surfacePoint) {
// Wall-anchored lattice: orient the plane into the wall and pin the mesh to
// the plane's FOOT (the point on the wall plane closest to the world origin)
// — never the moving ghost. Sliding the opening along the wall then only
// moves the reveal patch (the cursor uniform); the snap lattice stays put.
// (Copying `surfacePoint` here made the grid follow the item — useless.)
gridMesh.quaternion.setFromUnitVectors(PLANE_LOCAL_NORMAL, surfaceNormal)
const planeOffset = surfacePoint.dot(surfaceNormal)
gridMesh.position.copy(surfaceNormal).multiplyScalar(planeOffset)
// Cursor → plane-local XY: rotate (ghost anchor) by the inverse plane
// orientation. Both lie in the plane, so the resulting local Z is ~0.
invQuatRef.current.copy(gridMesh.quaternion).invert()
wallCursorRef.current
.copy(surfacePoint)
.sub(gridMesh.position)
.applyQuaternion(invQuatRef.current)
cursorPositionRef.current.set(wallCursorRef.current.x, wallCursorRef.current.y)
if (lastGridYRef.current !== surfacePoint.y) {
lastGridYRef.current = surfacePoint.y
setGridY(surfacePoint.y)
}
} else {
// Horizontal: keep the lattice anchored to world XZ (0,0); only the Y
// origin follows the surface height (floor / shelf top). Snap directly —
// the old lerp made the grid visibly drift up to a new floor height.
// Cursor uniform tracks the world cursor (mirrored on Z for the flat plane).
const targetY = surfacePoint ? surfacePoint.y : levelY
gridMesh.position.set(0, targetY, 0)
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
const world = lastWorldCursorRef.current const world = lastWorldCursorRef.current
if (world) { if (world) {
cursorPositionRef.current.set(world.x, -world.z) cursorPositionRef.current.set(world.x, -world.z)
} }
}) if (lastGridYRef.current !== targetY) {
lastGridYRef.current = targetY
setGridY(targetY)
}
}
const showGrid = useViewer((state) => state.showGrid) // Floor grid depth-tests against the scene (ground occludes a sub-floor
// lattice); the wall grid ignores depth so it stays visible through the wall
// when the opening is being handled from the opposite side.
if (material.depthTest === onWall) {
material.depthTest = !onWall
material.needsUpdate = true
}
// The grid is a placement aid: a tight cursor patch (no always-on baseline)
// shown whenever the active context is in grid-snap mode — ANY armed
// draft/build tool (wall / slab / fence / ceiling / zone / column / MEP / …),
// a node move, or a reshape — and hidden in select/idle, paint, and non-grid
// (lines/off) modes. `isGridSnapActive()` already derives the snap context
// from the interaction scope OR the armed build tool and is true only when
// that context resolves to grid, so it IS the gate. (Previously this also
// required a ghost in flight, so a merely-armed draft tool showed nothing.)
const snapPatchVisible = isGridSnapActive()
revealRadiusUniform.value = PLACEMENT_REVEAL_RADIUS
baseAlphaUniform.value = 0
cellSizeUniform.value = useEditor.getState().gridSnapStep
patchAlphaUniform.value = 1.5
gridRef.current.visible = snapPatchVisible
})
// Pass the geometry as a prop instead of a JSX child so the mesh // Pass the geometry as a prop instead of a JSX child so the mesh
// is never reconciled with R3F's empty placeholder `BufferGeometry`. // is never reconciled with R3F's empty placeholder `BufferGeometry`.
@@ -183,13 +290,14 @@ export const Grid = ({
useEffect(() => () => geometry.dispose(), [geometry]) useEffect(() => () => geometry.dispose(), [geometry])
return ( return (
// Orientation is driven imperatively in `useFrame` (horizontal by default,
// tilted into the wall plane while placing on a wall), so no static rotation.
<mesh <mesh
geometry={geometry} geometry={geometry}
layers={GRID_LAYER} layers={GRID_LAYER}
material={material} material={material}
ref={gridRef} ref={gridRef}
rotation-x={-Math.PI / 2} renderOrder={1}
visible={showGrid}
/> />
) )
} }
@@ -13,6 +13,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { import {
CORNER_OFFSET, CORNER_OFFSET,
@@ -44,7 +45,7 @@ export function GroupMoveHandle() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const movingNode = useEditor((s) => s.movingNode) const movingNode = useMovingNode()
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { import {
CORNER_OFFSET, CORNER_OFFSET,
@@ -55,7 +56,7 @@ export function GroupRotateHandle() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const movingNode = useEditor((s) => s.movingNode) const movingNode = useMovingNode()
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
// Re-derive participants whenever the scene mutates (e.g. after a commit). // Re-derive participants whenever the scene mutates (e.g. after a commit).
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag. // Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
@@ -279,15 +279,27 @@ export function createArrowHitAreaGeometry() {
return geometry return geometry
} }
// The move cross is a plus, not a disk. A disk-shaped hit area fills the four
// corner gaps between the arms, so a neighbouring node sitting next to the
// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the
// invisible grip and can't be picked. Wrap the visible arms instead: two flat
// arm boxes (length/width + margin) merged into a plus, leaving the corners
// empty so co-located neighbours stay selectable while the grip stays grabbable.
function createMoveCrossHitAreaGeometry() { function createMoveCrossHitAreaGeometry() {
const geometry = new CylinderGeometry( const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth)
HIT_AREA_THICKNESS, const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength)
32, const merged = mergeGeometries([armX, armZ], false)
) if (!merged) {
geometry.computeBoundingSphere() armZ.dispose()
return geometry armX.computeBoundingSphere()
return armX
}
armX.dispose()
armZ.dispose()
merged.computeBoundingSphere()
return merged
} }
export function createRotateArrowHitAreaGeometry() { export function createRotateArrowHitAreaGeometry() {
+123 -9
View File
@@ -2,6 +2,9 @@
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { import {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getSceneMaterialIdFromRef,
initSpaceDetectionSync, initSpaceDetectionSync,
initSpatialGridSync, initSpatialGridSync,
spatialGridManager, spatialGridManager,
@@ -19,6 +22,7 @@ import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard' import { useKeyboard } from '../../hooks/use-keyboard'
import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint'
import { import {
applySceneGraphToEditor, applySceneGraphToEditor,
loadSceneFromLocalStorage, loadSceneFromLocalStorage,
@@ -81,6 +85,7 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const SCENE_READY_FALLBACK_MS = 8000 const SCENE_READY_FALLBACK_MS = 8000
type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked'
const EDITOR_HOVER_STYLES: HoverStyles = { const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true }, default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false }, delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
@@ -535,14 +540,70 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
) )
} }
function getActivePaintMaterialSwatchColor(
material: ActivePaintMaterial | null,
sceneMaterials: ReturnType<typeof useScene.getState>['materials'],
) {
const directColor = material?.material?.properties?.color
if (directColor) return directColor
const sceneMaterialId = getSceneMaterialIdFromRef(material?.materialPreset)
if (sceneMaterialId) {
const sceneMaterial = sceneMaterials[sceneMaterialId as keyof typeof sceneMaterials]
const sceneColor = sceneMaterial?.material.properties?.color
if (sceneColor) return sceneColor
}
const catalogId =
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
const catalogMaterial = getCatalogMaterialById(catalogId)
return (
catalogMaterial?.previewColor ??
catalogMaterial?.preset.mapProperties.color ??
PAINT_CURSOR_BADGE_COLOR
)
}
function getActivePaintMaterialSwatchImageUrl(
material: ActivePaintMaterial | null,
sceneMaterials: ReturnType<typeof useScene.getState>['materials'],
) {
const directTextureUrl = material?.material?.texture?.url
if (directTextureUrl) return directTextureUrl
const sceneMaterialId = getSceneMaterialIdFromRef(material?.materialPreset)
if (sceneMaterialId) {
const sceneMaterial = sceneMaterials[sceneMaterialId as keyof typeof sceneMaterials]
const sceneTextureUrl = sceneMaterial?.material.texture?.url
if (sceneTextureUrl) return sceneTextureUrl
}
const catalogId =
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
const catalogMaterial = getCatalogMaterialById(catalogId)
return catalogMaterial?.previewThumbnailUrl ?? catalogMaterial?.preset.maps.albedoMap
}
function PaintCursorBadge({ function PaintCursorBadge({
position, position,
disabled, state,
swatchColor,
swatchImageUrl,
isEraser,
}: { }: {
position: { x: number; y: number } position: { x: number; y: number }
disabled: boolean state: PaintCursorBadgeState
swatchColor: string
swatchImageUrl?: string
isEraser: boolean
}) { }) {
const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR const accentColor =
state === 'ready'
? isEraser
? PAINT_CURSOR_BADGE_COLOR
: swatchColor
: PAINT_CURSOR_BADGE_DISABLED_COLOR
const iconOpacity = state === 'ready' ? 1 : state === 'blocked' ? 0.62 : 0.42
const lineHeight = 18 const lineHeight = 18
return ( return (
@@ -576,7 +637,48 @@ function PaintCursorBadge({
aria-hidden="true" aria-hidden="true"
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]" className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
src="/icons/paint.webp" src="/icons/paint.webp"
style={{
filter: state === 'ready' ? undefined : 'grayscale(1)',
opacity: iconOpacity,
}}
/> />
{state === 'ready' ? (
isEraser ? (
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/35 bg-zinc-950 text-white shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
<Icon
aria-hidden="true"
color="currentColor"
height={10}
icon="mdi:eraser-variant"
width={10}
/>
</span>
) : (
<span
className="-right-1 -bottom-1 absolute h-3.5 w-3.5 rounded-full border border-white/70 bg-cover bg-center shadow-[0_2px_6px_rgba(0,0,0,0.45)]"
style={{
backgroundColor: swatchColor,
backgroundImage: swatchImageUrl
? `url(${JSON.stringify(swatchImageUrl)})`
: undefined,
}}
/>
)
) : state === 'blocked' ? (
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/30 bg-zinc-950 text-rose-300 shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
<Icon
aria-hidden="true"
color="currentColor"
height={12}
icon="mdi:cancel"
width={12}
/>
</span>
) : (
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/30 bg-zinc-950 font-semibold text-[9px] text-slate-300 shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
?
</span>
)}
</div> </div>
</div> </div>
) )
@@ -730,6 +832,9 @@ function PaintCursorLayer({
}) { }) {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial) const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
const paintEraser = useEditor((s) => s.paintEraser)
const paintHover = useEditor((s) => s.paintHover)
const sceneMaterials = useScene((s) => s.materials)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null) const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const active = mode === 'material-paint' && !isVersionPreviewMode const active = mode === 'material-paint' && !isVersionPreviewMode
@@ -779,11 +884,14 @@ function PaintCursorLayer({
} }
}, [active, containerRef]) }, [active, containerRef])
const hasMaterial = Boolean( const hasPaint = paintEraser || hasActivePaintMaterial(activePaintMaterial)
activePaintMaterial && const badgeState: PaintCursorBadgeState = !hasPaint
(activePaintMaterial.material !== undefined || ? 'empty'
activePaintMaterial.materialPreset !== undefined), : paintHover != null
) ? 'ready'
: 'blocked'
const swatchColor = getActivePaintMaterialSwatchColor(activePaintMaterial, sceneMaterials)
const swatchImageUrl = getActivePaintMaterialSwatchImageUrl(activePaintMaterial, sceneMaterials)
if (!active || !position) return null if (!active || !position) return null
@@ -792,7 +900,13 @@ function PaintCursorLayer({
className="pointer-events-none absolute z-40" className="pointer-events-none absolute z-40"
style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }} style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }}
> >
<PaintCursorBadge disabled={!hasMaterial} position={{ x: 0, y: 0 }} /> <PaintCursorBadge
isEraser={paintEraser}
position={{ x: 0, y: 0 }}
state={badgeState}
swatchColor={swatchColor}
swatchImageUrl={swatchImageUrl}
/>
</div> </div>
) )
} }
@@ -1,10 +1,11 @@
'use client' 'use client'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { Copy, Move, Spline, Trash2 } from 'lucide-react' import { Copy, Move, Search, Spline, Trash2 } from 'lucide-react'
import type { MouseEventHandler, PointerEventHandler } from 'react' import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = { type NodeActionMenuProps = {
onFind?: MouseEventHandler<HTMLButtonElement>
onAddHole?: MouseEventHandler<HTMLButtonElement> onAddHole?: MouseEventHandler<HTMLButtonElement>
onDelete?: MouseEventHandler<HTMLButtonElement> onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement> onDuplicate?: MouseEventHandler<HTMLButtonElement>
@@ -17,6 +18,7 @@ type NodeActionMenuProps = {
} }
export function NodeActionMenu({ export function NodeActionMenu({
onFind,
onAddHole, onAddHole,
onDelete, onDelete,
onDuplicate, onDuplicate,
@@ -35,6 +37,17 @@ export function NodeActionMenu({
onPointerLeave={onPointerLeave} onPointerLeave={onPointerLeave}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
> >
{onFind && (
<button
aria-label="Find in catalog"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onFind}
title="Find in catalog"
type="button"
>
<Search className="h-4 w-4" />
</button>
)}
{onMove && ( {onMove && (
<button <button
aria-label="Move" aria-label="Move"
@@ -16,7 +16,6 @@ import {
sceneRegistry, sceneRegistry,
snapScalar, snapScalar,
type TapActionHandle, type TapActionHandle,
type TranslateHandle,
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -44,12 +43,17 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
import { createEditorApi } from '../../lib/editor-api' import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import useInteractionScope, {
useEndpointReshape,
useIsCurveReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import useOpeningGuides from '../../store/use-opening-guides' import useOpeningGuides from '../../store/use-opening-guides'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { formatAngleRadians } from '../tools/shared/segment-angle' import { formatAngleRadians } from '../tools/shared/segment-angle'
import { import {
ARROW_COLOR, ARROW_COLOR,
@@ -182,16 +186,13 @@ export function NodeArrowHandles() {
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId) const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useMovingNode()
const placementDragMode = useEditor((state) => state.placementDragMode)
// Endpoint / curve drags reshape the selected wall or fence; hide its // Endpoint / curve drags reshape the selected wall or fence; hide its
// resize arrows for the duration so they don't clutter (or get blocked // resize arrows for the duration so they don't clutter (or get blocked
// by) the drag's own cursor + dimension overlays. Mirrors the same guard // by) the drag's own cursor + dimension overlays. Mirrors the same guard
// on the legacy wall handles (`WallMoveSideHandles`). // on the legacy wall handles (`WallMoveSideHandles`).
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) const endpointReshape = useEndpointReshape()
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const isCurveReshape = useIsCurveReshape()
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
const rawNode = useScene((state) => const rawNode = useScene((state) =>
@@ -209,26 +210,31 @@ export function NodeArrowHandles() {
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode), () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
[rawNode, liveOverride], [rawNode, liveOverride],
) )
const isOwnPressDragMove =
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
const def = node ? nodeRegistry.get(node.type) : null const def = node ? nodeRegistry.get(node.type) : null
const descriptors = useMemo(() => { const descriptors = useMemo(() => {
if (!(node && def?.handles)) return null if (!(node && def?.handles)) return null
return typeof def.handles === 'function' const all =
typeof def.handles === 'function'
? def.handles(node as never) ? def.handles(node as never)
: (def.handles as HandleDescriptor[]) : (def.handles as HandleDescriptor[])
// The whole-node move-cross gizmo is gone: moving is now click-to-move on
// the selected node body (see selection-manager). Drop both flavours — the
// `translate` ground cross (column/roof/shelf/spawn) and the `tap-action`
// `move-cross` (item/door/window/elevator/stair) — keep rotate/resize.
return all.filter((d) => d.kind !== 'translate' && !('shape' in d && d.shape === 'move-cross'))
}, [node, def]) }, [node, def])
const shouldRender = const shouldRender =
Boolean(node && descriptors?.length) && Boolean(node && descriptors?.length) &&
!isFloorplanHovered && !isFloorplanHovered &&
mode !== 'delete' && mode !== 'delete' &&
(!movingNode || isOwnPressDragMove) && // Any whole-node move (placement or press-drag) hides the rig: the item is
!movingWallEndpoint && // following the cursor, so its rotate/resize handles would only clutter and
!movingFenceEndpoint && // draw stray selection rays. The active handle-drag scope (resize/rotate)
!curvingWall && // sets `activeHandleDrag`, not `movingNode`, so those are unaffected.
!curvingFence !movingNode &&
!endpointReshape &&
!isCurveReshape
if (!shouldRender || !node || !descriptors) return null if (!shouldRender || !node || !descriptors) return null
// Key by the selected node id so switching selection REMOUNTS the rig. // Key by the selected node id so switching selection REMOUNTS the rig.
@@ -419,8 +425,14 @@ function NodeArrowHandlesForNode({
// resize that re-centres the mesh) must NOT fire for the non-active arrows // resize that re-centres the mesh) must NOT fire for the non-active arrows
// here, or they'd lag behind the moving item. // here, or they'd lag behind the moving item.
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate' const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
// While a rotate gizmo is mid-drag, drop the opposite-side move cross: you
// can't move and rotate at once, so it only clutters the rotation.
const activeDescriptor = activeIndex !== null ? descriptors[activeIndex] : undefined
const activeIsRotate =
!!activeDescriptor && 'shape' in activeDescriptor && activeDescriptor.shape === 'rotate'
const arrows = descriptors.map((descriptor, index) => { const arrows = descriptors.map((descriptor, index) => {
if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
// A `latch` cube toggles its group's visibility; render it always. // A `latch` cube toggles its group's visibility; render it always.
if (descriptor.kind === 'latch') { if (descriptor.kind === 'latch') {
return ( return (
@@ -442,6 +454,8 @@ function NodeArrowHandlesForNode({
descriptor={descriptor} descriptor={descriptor}
dragControls={dragControls} dragControls={dragControls}
handleIndex={index} handleIndex={index}
// Descriptors come from a per-node-kind static list, so index is a
// stable identity within this node's selection cycle.
key={index} key={index}
liveNode={node} liveNode={node}
preDragNode={preDragNode} preDragNode={preDragNode}
@@ -549,17 +563,6 @@ function ArrowHandle({
/> />
) )
} }
if (descriptor.kind === 'translate') {
return (
<TranslateArrow
descriptor={descriptor}
dragControls={dragControls}
handleIndex={handleIndex}
node={placementNode}
rideObject={rideObject}
/>
)
}
if (descriptor.kind === 'tap-action') { if (descriptor.kind === 'tap-action') {
// Tap-action handles (fence side-move arrows, corner pickers) aren't // Tap-action handles (fence side-move arrows, corner pickers) aren't
// resize handles, so the freeze-at-pre-drag mechanism — which only // resize handles, so the freeze-at-pre-drag mechanism — which only
@@ -712,14 +715,18 @@ function LinearArrow({
return { return {
overrideId, overrideId,
onBegin: () => { onBegin: () => {
if (measureLabel) { // Always claim the handle-drag scope so the HUD knows a resize is the
useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel }) // active interaction (keeps the idle select hints off-screen). The
} // dimension-pill handles carry their `measureLabel`; plain resize
// arrows use the generic label.
useInteractionScope.getState().begin({
kind: 'handle-drag',
nodeId,
handle: measureLabel ?? RESIZE_HANDLE_DRAG_LABEL,
})
}, },
onEnd: () => { onEnd: () => {
if (measureLabel) { useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
useEditor.getState().setActiveHandleDrag(null)
}
if (onDrag) useOpeningGuides.getState().clear() if (onDrag) useOpeningGuides.getState().clear()
}, },
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
@@ -1190,8 +1197,23 @@ function ArcArrow({
} }
const initialAngle = angleOf(hitWorld) const initialAngle = angleOf(hitWorld)
// Advertise the rotate interaction so the contextual HUD can surface the
// Shift = free-rotation toggle (the angle-step bypass below). Resize
// handles route a measurement label here; rotate gets a sentinel label so
// the HUD shows the rotate hint, not a dimension pill.
if (isRotateShape) {
useInteractionScope
.getState()
.begin({ kind: 'handle-drag', nodeId: node.id, handle: ROTATE_HANDLE_DRAG_LABEL })
}
return { return {
onEnd: () => setRotationDelta(null), onEnd: () => {
setRotationDelta(null)
if (isRotateShape) {
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
}
},
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
const hit = new Vector3() const hit = new Vector3()
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
@@ -1253,65 +1275,6 @@ function ArcArrow({
) )
} }
// Free ground-plane move gizmo (the 4-way cross). Press-drag-release: raycast
// the horizontal plane at the node's base, convert the hit into the node's
// parent-local frame, add the delta to the node's drag-start position, grid-
// snap via the descriptor's `snapExtents`, and publish to `useLiveNodeOverrides`
// each move — committing one write to the store on release. The override stays
// at base Y; `<FloorElevationSystem>` reads that effective node and owns the
// presentation-only slab lift so the handle path shares the menu-move stacking
// contract without storing lifted positions.
function TranslateArrow({
descriptor,
node,
}: {
descriptor: TranslateHandle<AnyNode>
node: AnyNode
handleIndex: number
dragControls: HandleDragControls
rideObject: Object3D
}) {
const [isHovered, setIsHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom * ARROW_SCALE
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
const position = descriptor.placement.position(node, placementSceneApi)
const cursor: Cursor = 'move'
// 'node-normal' constrains the drag to the wall face (plane ⟂ the node's
// local +Z). Its cross icon stands up into that plane (tilt about X).
const isWallPlane = descriptor.plane === 'node-normal'
// Same function as the floating action menu's Move button
// (`floating-action-menu.tsx` → `handleMove`): arm the registry move tool,
// which owns the cursor follow, grid + alignment snap, green guide overlay,
// and click-to-commit. Routes both entry points through one path so the
// 3D translate gizmo and the floating Move button behave identically.
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(node as never)
useViewer.getState().setSelection({ selectedIds: [] })
}
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
// it lies in the item-local XY plane (= the wall face).
const iconRotation: [number, number, number] = isWallPlane ? NODE_NORMAL_TILT : [0, 0, 0]
return (
<HandleArrow
cursor={cursor}
hover={isHovered}
onHoverChange={setIsHovered}
onPointerDown={activate}
placement={{ position, rotation: iconRotation, baseScale }}
shape="cross"
/>
)
}
// Click-to-engage affordance — no drag plumbing, just a click target. The // Click-to-engage affordance — no drag plumbing, just a click target. The
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage // descriptor's `onActivate` receives sceneApi + editorApi so it can engage
// move tools, endpoint drags, or any other editor-state transition without // move tools, endpoint drags, or any other editor-state transition without
@@ -2,15 +2,11 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
type CeilingNode,
type ColumnNode,
createSceneApi, createSceneApi,
emitter, emitter,
type FenceNode,
type GridEvent, type GridEvent,
getEffectiveRoofSurfaceMaterial, getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial, getEffectiveSegmentSurfaceMaterial,
getMaterialPresetByRef,
getRoofSegmentSurfaceY, getRoofSegmentSurfaceY,
getSelectableKinds, getSelectableKinds,
type ItemNode, type ItemNode,
@@ -22,11 +18,7 @@ import {
type RoofSegmentEvent, type RoofSegmentEvent,
type RoofSegmentNode, type RoofSegmentNode,
resolveLevelId, resolveLevelId,
resolveMaterial,
type ShelfNode,
type SlabNode,
type StairEvent, type StairEvent,
type StairNode,
type StairSegmentEvent, type StairSegmentEvent,
type StairSurfaceMaterialRole, type StairSurfaceMaterialRole,
sceneRegistry, sceneRegistry,
@@ -35,14 +27,12 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
applyMaterialPresetToMaterials,
createMaterial, createMaterial,
createMaterialFromPresetRef, createMaterialFromPresetRef,
getRoofMaterialArray, getRoofMaterialArray,
getStairBodyMaterials,
getStairRailingMaterial,
useViewer, useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three' import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
import { import {
@@ -56,11 +46,17 @@ import {
type ActivePaintMaterial, type ActivePaintMaterial,
buildRoofSegmentSurfaceMaterialPatch, buildRoofSegmentSurfaceMaterialPatch,
buildRoofSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
hasActivePaintMaterial, hasActivePaintMaterial,
resolveActivePaintMaterialFromSelection, resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint' } from '../../lib/material-paint'
import {
availablePaintScopes,
commitPaintScopeFanout,
nodeSlotRoles,
type PaintHoverInfo,
resolvePaintScopeTargets,
slotDisplayLabel,
} from '../../lib/paint-scope'
import { import {
resolveNodeSelectionTarget, resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick, resolveSelectedIdsForNodeClick,
@@ -70,6 +66,12 @@ import {
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor, { type MaterialTargetRole } from './../../store/use-editor' import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
import useInteractionScope, {
getEditingHole,
getMovingNode,
useIsCurveReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { swallowNextClick } from './node-arrow-handles' import { swallowNextClick } from './node-arrow-handles'
@@ -108,6 +110,9 @@ type PaintInteraction = {
hoverMode: HoverHighlightMode hoverMode: HoverHighlightMode
hoveredId: AnyNodeId hoveredId: AnyNodeId
preview: (() => PaintPreviewCleanup | null) | null preview: (() => PaintPreviewCleanup | null) | null
// What the paint HUD chip should show for this hover (scopes + labels), or
// null when the surface isn't paintable.
paintHover: PaintHoverInfo | null
} }
interface SelectionStrategy { interface SelectionStrategy {
@@ -234,6 +239,28 @@ function getRegisteredMesh(nodeId: string): Mesh | null {
return object && (object as Mesh).isMesh ? (object as Mesh) : null return object && (object as Mesh).isMesh ? (object as Mesh) : null
} }
// Every distinct slot role on a node, read off the registered mesh subtree's
// `userData.slotId` tags (a tag may be a single role or an array, one per
// material group). The mesh-derived fallback behind `nodeSlotRoles` for kinds
// whose slots come from a GLB (items) rather than a `capabilities.slots`
// declaration; returns `[]` when the subtree isn't mounted.
function meshSlotRoles(node: AnyNode): string[] {
const root = getRegisteredNodeObject(node.id)
if (!root) return []
const roles = new Set<string>()
root.traverse((object) => {
const mesh = object as Mesh
if (!mesh.isMesh) return
const tag = (mesh.userData as { slotId?: string | null | (string | null)[] }).slotId
if (Array.isArray(tag)) {
for (const entry of tag) if (typeof entry === 'string') roles.add(entry)
} else if (typeof tag === 'string') {
roles.add(tag)
}
})
return [...roles]
}
const roofSelectionWorldPoint = new Vector3() const roofSelectionWorldPoint = new Vector3()
function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null { function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
@@ -303,20 +330,6 @@ function previewCursor(cursor: string): PaintPreviewCleanup {
} }
} }
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
const shading = useViewer.getState().shading
if (material.materialPreset) {
return createMaterialFromPresetRef(material.materialPreset, shading)
}
if (material.material) {
return createMaterial(material.material, shading)
}
return null
}
function applyRoofPaintPreview( function applyRoofPaintPreview(
node: RoofNode, node: RoofNode,
role: 'top' | 'edge' | 'wall', role: 'top' | 'edge' | 'wall',
@@ -382,164 +395,6 @@ function applyRoofSegmentPaintPreview(
return previewMeshMaterial(mesh, arr) return previewMeshMaterial(mesh, arr)
} }
function applyStairPaintPreview(
node: StairNode,
role: StairSurfaceMaterialRole,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const root = getRegisteredNodeObject(node.id)
if (!root) return null
const previewNode = {
...node,
...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
const shading = useViewer.getState().shading
const bodyMaterials = getStairBodyMaterials(previewNode, shading)
const railingMaterial = getStairRailingMaterial(previewNode, shading)
const restores: PaintPreviewCleanup[] = []
root.traverse((object) => {
if (!(object as Mesh).isMesh) return
const mesh = object as Mesh
if (mesh.name.startsWith('stair-railing')) {
restores.push(previewMeshMaterial(mesh, railingMaterial))
return
}
if (Array.isArray(mesh.material) && mesh.material.length === 2) {
restores.push(previewMeshMaterial(mesh, bodyMaterials))
return
}
if (mesh.name === 'merged-stair') {
restores.push(previewMeshMaterial(mesh, bodyMaterials))
return
}
if (mesh.name.startsWith('stair-side')) {
restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
}
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
function applySingleSurfacePaintPreview(
node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
if (node.type === 'ceiling') {
const root = getRegisteredMesh(node.id)
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
if (!(root && overlay)) return null
const previewColor =
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
resolveMaterial(material.material).color ??
'#999999'
const previousRootMaterial = root.material
const previousOverlayMaterial = overlay.material
const rootPreviewMaterial = Array.isArray(previousRootMaterial)
? previousRootMaterial.map((entry) => entry.clone())
: previousRootMaterial.clone()
const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
? previousOverlayMaterial.map((entry) => entry.clone())
: previousOverlayMaterial.clone()
const applyColor = (input: Material | Material[]) => {
const materials = Array.isArray(input) ? input : [input]
for (const entry of materials) {
const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
if (materialWithColor.color instanceof Color) {
materialWithColor.color = new Color(previewColor)
}
materialWithColor.needsUpdate = true
}
}
applyColor(rootPreviewMaterial)
applyColor(overlayPreviewMaterial)
root.material = rootPreviewMaterial
overlay.material = overlayPreviewMaterial
return () => {
root.material = previousRootMaterial
overlay.material = previousOverlayMaterial
}
}
const registeredObject = getRegisteredNodeObject(node.id)
const mesh =
registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null
const previewMaterial = getSingleSurfacePreviewMaterial(material)
if (!previewMaterial) return null
if (node.type === 'column') {
if (!registeredObject) return null
const restores: PaintPreviewCleanup[] = []
registeredObject.traverse((object) => {
if (!(object as Mesh).isMesh) return
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
if (node.type === 'shelf') {
// Shelf registers a `<group>` (not a Mesh) with `useRegistry`, so we walk
// the subtree and preview-swap every child mesh — same approach `column`
// uses. (The roof vents previously shared this arm; they now route through
// their `capabilities.paint` dispatcher.)
if (!registeredObject) return null
const restores: PaintPreviewCleanup[] = []
registeredObject.traverse((object) => {
if (!(object as Mesh).isMesh) return
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
if (!mesh) return null
if (node.type === 'slab') {
const slabMaterial = previewMaterial.clone()
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
const previewMeshMaterialInput = slabMaterial as Material & {
alphaMap?: unknown
depthWrite?: boolean
needsUpdate?: boolean
opacity?: number
side?: number
transparent?: boolean
}
previewMeshMaterialInput.transparent = false
previewMeshMaterialInput.opacity = 1
previewMeshMaterialInput.alphaMap = null
previewMeshMaterialInput.depthWrite = true
previewMeshMaterialInput.needsUpdate = true
return previewMeshMaterial(mesh, slabMaterial)
}
return previewMeshMaterial(mesh, previewMaterial)
}
// Chimney + dormer paint dispatch lives on their NodeDefinition's // Chimney + dormer paint dispatch lives on their NodeDefinition's
// `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/ // `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
// paint.ts). The generic registry-driven arm in this file consults // paint.ts). The generic registry-driven arm in this file consults
@@ -847,6 +702,10 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
export const SelectionManager = () => { export const SelectionManager = () => {
const phase = useEditor((s) => s.phase) const phase = useEditor((s) => s.phase)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
// The canvas element — cursor styling must land here, not on `document.body`:
// the editor wraps the canvas in a div with a custom `cursor: url(...)`, which
// (being a closer ancestor) overrides any body cursor over the canvas.
const glDomElement = useThree((s) => s.gl.domElement)
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode) const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
const modifierKeysRef = useRef<SelectionModifierKeys>({ const modifierKeysRef = useRef<SelectionModifierKeys>({
meta: false, meta: false,
@@ -855,9 +714,8 @@ export const SelectionManager = () => {
}) })
const clickHandledRef = useRef(false) const clickHandledRef = useRef(false)
const movingNode = useEditor((s) => s.movingNode) const movingNode = useMovingNode()
const curvingWall = useEditor((s) => s.curvingWall) const isCurveReshape = useIsCurveReshape()
const curvingFence = useEditor((s) => s.curvingFence)
useEffect(() => { useEffect(() => {
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default' const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
@@ -870,9 +728,12 @@ export const SelectionManager = () => {
useEffect(() => { useEffect(() => {
if (mode !== 'material-paint') return if (mode !== 'material-paint') return
if (movingNode || curvingWall) return if (movingNode || isCurveReshape) return
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
// The last hover event, replayed when the application scope cycles so the
// preview + chip update under a stationary cursor (Shift fires no pointer move).
let lastEnterEvent: NodeEvent | null = null
const clearActivePreview = () => { const clearActivePreview = () => {
activePreview?.restore() activePreview?.restore()
@@ -934,13 +795,52 @@ export const SelectionManager = () => {
ray: event.nativeEvent.ray, ray: event.nativeEvent.ray,
}) })
const compatible = role !== null && paintEnabled const compatible = role !== null && paintEnabled
// Derive the node's slots (declared, else mesh tags) once — drives both
// the chip's available scopes and the whole-object fan-out.
const slotRoles = compatible && role ? nodeSlotRoles(node, meshSlotRoles) : []
// Resolve the application-scope fan-out once (this surface / whole object
// / all matching / room). The scope is part of the key so cycling it
// (Shift) re-keys the interaction → the preview re-applies for the new
// spread instead of being deduped to the single-surface preview.
const scope = useEditor.getState().paintScope
const scopeTargets =
compatible && role
? resolvePaintScopeTargets({
node,
role,
scope,
nodes: useScene.getState().nodes,
spaces: useEditor.getState().spaces,
slotRolesOf: () => slotRoles,
})
: []
return { return {
key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}:${scope}`,
hoveredId: node.id as AnyNodeId, hoveredId: node.id as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled', hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
paintHover:
compatible && role
? {
scopes: availablePaintScopes({ node, slotRoles }),
slotLabel: slotDisplayLabel(node, role),
nodeNoun: node.type,
}
: null,
apply: apply:
compatible && role compatible && role
? () => { ? () => {
// Spread targets are all the same slot-model kind, so one
// batched commit writes them in a single undo step; the
// single-surface case keeps the kind's own commit (covers
// non-slot kinds too).
if (scopeTargets.length > 1) {
commitPaintScopeFanout(
scopeTargets,
paintSpec.material,
paintSpec.materialPreset,
)
return
}
const args = { const args = {
node, node,
role, role,
@@ -962,15 +862,33 @@ export const SelectionManager = () => {
preview: preview:
compatible && role compatible && role
? () => { ? () => {
const root = getRegisteredNodeObject(node.id) // Preview every surface the click would paint, so room /
if (!root) return null // whole-item / all-matching show the full spread, not just the
return paintCap.applyPreview({ // hovered surface. Each target is the same kind, so its own
node, // paint capability builds the preview; restores combine.
role, const restores: PaintPreviewCleanup[] = []
const sceneNodes = useScene.getState().nodes
for (const target of scopeTargets) {
const targetNode = sceneNodes[target.nodeId]
const targetRoot = getRegisteredNodeObject(target.nodeId)
const targetCap = targetNode
? nodeRegistry.get(targetNode.type)?.capabilities?.paint
: null
if (!(targetNode && targetRoot && targetCap)) continue
const restore = targetCap.applyPreview({
node: targetNode,
role: target.role,
material: paintSpec.material, material: paintSpec.material,
materialPreset: paintSpec.materialPreset, materialPreset: paintSpec.materialPreset,
root, root: targetRoot,
}) })
if (restore) restores.push(restore)
}
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1)
restores[index]?.()
}
} }
: () => previewCursor('not-allowed'), : () => previewCursor('not-allowed'),
} }
@@ -999,6 +917,16 @@ export const SelectionManager = () => {
}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId, hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled', hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
// Roof isn't on the slot model (role-specific fields, custom commit),
// so it offers only the single surface — but still labels it.
paintHover:
compatible && role
? {
scopes: ['single'],
slotLabel: slotDisplayLabel(roofNode, role),
nodeNoun: 'roof',
}
: null,
apply: apply:
compatible && role compatible && role
? () => { ? () => {
@@ -1041,77 +969,9 @@ export const SelectionManager = () => {
} }
} }
if (node.type === 'stair' || node.type === 'stair-segment') { // Only `roof` / `roof-segment` reach a legacy paint arm (above) — every
const stairNode = // other paintable kind declares `capabilities.paint` and returns from the
node.type === 'stair' // registry-driven dispatch at the top of this function.
? node
: node.parentId
? useScene.getState().nodes[node.parentId as AnyNodeId]
: null
if (!stairNode || stairNode.type !== 'stair') return null
const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent)
const compatible = role !== null && paintEnabled
return {
key: `stair:${stairNode.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
hoveredId: stairNode.id as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
apply:
compatible && role
? () => {
useScene
.getState()
.updateNode(
stairNode.id as AnyNodeId,
buildStairSurfaceMaterialPatch(
stairNode as StairNode,
role,
paintSpec.material,
paintSpec.materialPreset,
),
)
}
: null,
preview:
compatible && role
? () => applyStairPaintPreview(stairNode as StairNode, role, paintSpec)
: () => previewCursor('not-allowed'),
}
}
// Registry-driven paint dispatch handled at the top of this
// function — kinds declaring `capabilities.paint` return there
// before any of the legacy roof / stair / single-surface arms
// below run.
if (node.type === 'fence' || node.type === 'column' || node.type === 'shelf') {
const compatible = paintEnabled
return {
key: `${node.type}:${node.id}:surface:${eraser ? 'erase' : 'paint'}`,
hoveredId: node.id as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
apply: compatible
? () => {
useScene
.getState()
.updateNode(
node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch<
FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
>(paintSpec.material, paintSpec.materialPreset),
)
}
: null,
preview: compatible
? () =>
applySingleSurfacePaintPreview(
node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
paintSpec,
)
: () => previewCursor('not-allowed'),
}
}
const disabledNodeTypes = ['zone'] const disabledNodeTypes = ['zone']
if (disabledNodeTypes.includes(node.type)) { if (disabledNodeTypes.includes(node.type)) {
@@ -1119,6 +979,7 @@ export const SelectionManager = () => {
key: `${node.type}:${node.id}:unsupported`, key: `${node.type}:${node.id}:unsupported`,
hoveredId: node.id as AnyNodeId, hoveredId: node.id as AnyNodeId,
hoverMode: 'paint-disabled', hoverMode: 'paint-disabled',
paintHover: null,
apply: null, apply: null,
preview: () => previewCursor('not-allowed'), preview: () => previewCursor('not-allowed'),
} }
@@ -1138,6 +999,12 @@ export const SelectionManager = () => {
if (!interaction) return if (!interaction) return
event.stopPropagation() event.stopPropagation()
lastEnterEvent = event
// Drive the paint HUD off this hover: the interaction carries the scopes +
// labels for the painted surface (`null` when it isn't paintable — no
// slots, etc. — which makes the HUD show the "hover a surface" hint).
useEditor.getState().setPaintHover(interaction.paintHover)
if (activePreview?.key === interaction.key) { if (activePreview?.key === interaction.key) {
return return
@@ -1157,6 +1024,10 @@ export const SelectionManager = () => {
const interaction = getPaintInteraction(event) const interaction = getPaintInteraction(event)
if (!interaction) return if (!interaction) return
// Leaving any surface → the HUD shows the "hover a surface" hint again.
lastEnterEvent = null
useEditor.getState().setPaintHover(null)
if (activePreview?.key !== interaction.key) { if (activePreview?.key !== interaction.key) {
return return
} }
@@ -1224,7 +1095,16 @@ export const SelectionManager = () => {
emitter.on(`${type}:click` as any, onClick as any) emitter.on(`${type}:click` as any, onClick as any)
} }
// Cycling the application scope (Shift) fires no pointer event, so replay
// the last hover to re-resolve the spread and re-apply the preview at once.
const unsubscribePaintScope = useEditor.subscribe((state, prev) => {
if (state.paintScope === prev.paintScope || !lastEnterEvent) return
clearActivePreview()
onEnter(lastEnterEvent)
})
return () => { return () => {
unsubscribePaintScope()
for (const type of subscribedKinds) { for (const type of subscribedKinds) {
emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:move` as any, onEnter as any) emitter.off(`${type}:move` as any, onEnter as any)
@@ -1234,8 +1114,9 @@ export const SelectionManager = () => {
clearActivePreview() clearActivePreview()
useViewer.setState({ hoveredId: null }) useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default') setHoverHighlightMode('default')
useEditor.getState().setPaintHover(null)
} }
}, [curvingWall, mode, movingNode, setHoverHighlightMode]) }, [isCurveReshape, mode, movingNode, setHoverHighlightMode])
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -1269,7 +1150,7 @@ export const SelectionManager = () => {
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return if (movingNode || isCurveReshape) return
const onPointerDown = (event: NodeEvent) => { const onPointerDown = (event: NodeEvent) => {
const pointer = pointerEventFromNodeEvent(event) const pointer = pointerEventFromNodeEvent(event)
@@ -1306,7 +1187,7 @@ export const SelectionManager = () => {
swallowNextClick() swallowNextClick()
createEditorApi().engageMoveDrag(node) createEditorApi().engageMoveDrag(node)
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (useEditor.getState().movingNode?.id !== node.id) return if (getMovingNode()?.id !== node.id) return
pointerTarget?.dispatchEvent( pointerTarget?.dispatchEvent(
new PointerEvent('pointermove', { new PointerEvent('pointermove', {
altKey: moveEvent.altKey, altKey: moveEvent.altKey,
@@ -1330,7 +1211,7 @@ export const SelectionManager = () => {
if (engaged) { if (engaged) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const editor = useEditor.getState() const editor = useEditor.getState()
if (editor.movingNode?.id !== node.id || !editor.placementDragMode) return if (getMovingNode()?.id !== node.id || !editor.placementDragMode) return
editor.setMovingNode(null) editor.setMovingNode(null)
}) })
} }
@@ -1374,11 +1255,57 @@ export const SelectionManager = () => {
emitter.off(`${type}:pointerdown` as any, onPointerDown as any) emitter.off(`${type}:pointerdown` as any, onPointerDown as any)
} }
} }
}, [curvingFence, curvingWall, mode, movingNode]) }, [isCurveReshape, mode, movingNode])
// Move cursor over the selected movable node: the visual cue that clicking it
// picks it up (replaces the removed move-cross gizmo). Reacts only when the
// hovered/selected node changes (not on every camera move) so it doesn't fight
// the rotate/resize gizmos' own hover cursors. Clears only the cursor it owns.
useEffect(() => {
if (mode !== 'select') return
let owns = false
let prevKey = ''
const applyCursor = () => {
const { selection, hoveredId } = useViewer.getState()
const sole = selection.selectedIds.length === 1 ? selection.selectedIds[0] : null
const key = `${hoveredId ?? ''}|${sole ?? ''}`
if (key === prevKey) return
prevKey = key
const node =
sole != null && hoveredId === sole && !getMovingNode()
? useScene.getState().nodes[sole as AnyNodeId]
: null
if (node && canDirectMoveNode(node)) {
glDomElement.style.cursor = 'move'
owns = true
} else if (owns) {
glDomElement.style.cursor = ''
owns = false
}
}
applyCursor()
const unsub = useViewer.subscribe(applyCursor)
return () => {
unsub()
if (owns) glDomElement.style.cursor = ''
}
}, [mode, glDomElement])
// While a node is actively being moved (click-to-move / Move button, or a
// fresh preset placement), show a grabbing hand. Mode-independent: presets
// move in build mode. Overrides the hover 'move' cursor (which bails while a
// movingNode exists), and clears back to the canvas's custom cursor on drop.
useEffect(() => {
if (!movingNode) return
glDomElement.style.cursor = 'grabbing'
return () => {
glDomElement.style.cursor = ''
}
}, [movingNode, glDomElement])
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return if (movingNode || isCurveReshape) return
const onPointerDown = (event: PointerEvent) => { const onPointerDown = (event: PointerEvent) => {
if (event.button !== 2 || !isCommandModifier(event)) return if (event.button !== 2 || !isCommandModifier(event)) return
@@ -1481,16 +1408,26 @@ export const SelectionManager = () => {
return () => { return () => {
window.removeEventListener('pointerdown', onPointerDown, true) window.removeEventListener('pointerdown', onPointerDown, true)
} }
}, [curvingFence, curvingWall, mode, movingNode]) }, [isCurveReshape, mode, movingNode])
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return if (movingNode || isCurveReshape) return
const onClick = (event: NodeEvent) => { const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node) // Skip if box-select just completed (drag ended over a node)
if (boxSelectHandled) return if (boxSelectHandled) return
// node:click is synthesized on pointer-up (use-node-events). A wall/fence
// endpoint handle sits ON the wall body, so from a 3D angle the wall mesh
// is raycast-hit behind it and the SAME pointer-up also emits the wall's
// click — which would select + arm the wall move tool on top of the
// endpoint move. While an endpoint reshape owns the pointer, ignore the
// body click so only the reshape tool handles the release. (Scoped to
// `endpoint`: hole-edit relies on node clicks to exit, just below.)
const activeScope = useInteractionScope.getState().scope
if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return
const node = event.node const node = event.node
// A ceiling is selectable only through its corner handles, never via // A ceiling is selectable only through its corner handles, never via
@@ -1533,6 +1470,15 @@ export const SelectionManager = () => {
if (activeStrategy?.isValid(node)) { if (activeStrategy?.isValid(node)) {
event.stopPropagation() event.stopPropagation()
clickHandledRef.current = true clickHandledRef.current = true
// Reset the handled flag after a short delay so the grid:click that the
// SAME DOM click also raycasts is ignored (it fires synchronously, before
// this 50ms macrotask). Scheduled here — right after the flag is set — so
// EVERY branch below clears it, including the click-to-move early return
// (which previously skipped the reset and left empty-click deselect stuck
// until the next normal select).
setTimeout(() => {
clickHandledRef.current = false
}, 50)
let nodeToSelect = node let nodeToSelect = node
if (node.type === 'roof-segment' && node.parentId) { if (node.type === 'roof-segment' && node.parentId) {
@@ -1557,8 +1503,27 @@ export const SelectionManager = () => {
// Clicking any node (e.g. the slab surface outside a hole) exits slab // Clicking any node (e.g. the slab surface outside a hole) exits slab
// hole-edit mode. The hole handles + hit mesh stopPropagation, so a // hole-edit mode. The hole handles + hit mesh stopPropagation, so a
// click reaching here means the user clicked outside the hole. // click reaching here means the user clicked outside the hole.
if (useEditor.getState().editingHole) { if (getEditingHole()) {
useEditor.getState().setEditingHole(null) useInteractionScope
.getState()
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
}
// Click-to-move: clicking the already-selected sole movable node with
// no modifiers picks it up instead of re-selecting — the move-cross
// gizmo's old job, now on the node body. `setMovingNode` arms the
// registry move tool in click-to-commit mode, exactly like the floating
// Move button. The first (selecting) click can't hit this because the
// node isn't yet in `selectedIdsBeforeRouting`.
const nativeEvent = event.nativeEvent
const hasModifier = nativeEvent.shiftKey || isCommandModifier(nativeEvent)
const isAlreadySole =
selectedIdsBeforeRouting.length === 1 && selectedIdsBeforeRouting[0] === nodeToSelect.id
if (!hasModifier && isAlreadySole && !getMovingNode() && canDirectMoveNode(nodeToSelect)) {
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(nodeToSelect as never)
useViewer.getState().setSelection({ selectedIds: [] })
return
} }
activeStrategy.handleSelect( activeStrategy.handleSelect(
@@ -1636,11 +1601,6 @@ export const SelectionManager = () => {
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) { if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
useEditor.getState().setSelectedMaterialTarget(null) useEditor.getState().setSelectedMaterialTarget(null)
} }
// Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => {
clickHandledRef.current = false
}, 50)
} }
} }
@@ -1697,12 +1657,12 @@ export const SelectionManager = () => {
}) })
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
} }
}, [curvingFence, curvingWall, mode, movingNode]) }, [isCurveReshape, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover // Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return if (movingNode || isCurveReshape) return
const onEnter = (event: NodeEvent) => { const onEnter = (event: NodeEvent) => {
// A host-driven drag (handle resize/rotate, box-select) sets // A host-driven drag (handle resize/rotate, box-select) sets
@@ -1843,7 +1803,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any) emitter.off(`${type}:double-click` as any, onDoubleClick as any)
}) })
} }
}, [curvingFence, curvingWall, mode, movingNode]) }, [isCurveReshape, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool) // Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => { useEffect(() => {
@@ -9,7 +9,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import { type Camera, type Object3D, Vector3 } from 'three' import { type Camera, type Object3D, Vector3 } from 'three'
import { formatLinearMeasurement } from '../../lib/measurements' import { formatLinearMeasurement } from '../../lib/measurements'
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
import useEditor from '../../store/use-editor' import { useActiveHandleDrag } from '../../store/use-interaction-scope'
type ViewportSize = { type ViewportSize = {
width: number width: number
@@ -37,7 +37,7 @@ export function SiteEdgeLabels() {
const node = state.nodes[firstRoot] const node = state.nodes[firstRoot]
return node?.type === 'site' ? (node as SiteNode) : null return node?.type === 'site' ? (node as SiteNode) : null
}) })
const activeHandleDrag = useEditor((state) => state.activeHandleDrag) const activeHandleDrag = useActiveHandleDrag()
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const cameraMode = useViewer((state) => state.cameraMode) const cameraMode = useViewer((state) => state.cameraMode)
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
@@ -25,7 +25,9 @@ import {
} from 'three' } from 'three'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import { holeEditScope } from '../../lib/interaction/scope'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import useInteractionScope, { useEditingHole } from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { swallowNextClick } from './handles/use-handle-drag' import { swallowNextClick } from './handles/use-handle-drag'
@@ -254,7 +256,7 @@ function SelectedSlabHoleHighlights({ slabId }: { slabId: string }) {
const node = useScene((state) => state.nodes[slabId as AnyNodeId]) const node = useScene((state) => state.nodes[slabId as AnyNodeId])
const override = useLiveNodeOverrides((state) => state.overrides.get(slabId)) const override = useLiveNodeOverrides((state) => state.overrides.get(slabId))
const hoveredHole = useEditor((state) => state.hoveredHole) const hoveredHole = useEditor((state) => state.hoveredHole)
const editingHole = useEditor((state) => state.editingHole) const editingHole = useEditingHole()
const setHoveredHole = useEditor((state) => state.setHoveredHole) const setHoveredHole = useEditor((state) => state.setHoveredHole)
const slab = node?.type === 'slab' ? (node as SlabNode) : null const slab = node?.type === 'slab' ? (node as SlabNode) : null
@@ -389,21 +391,25 @@ function SlabHoleHighlight({
// user edits the source rather than the synced hole. Everything else — // user edits the source rather than the synced hole. Everything else —
// manual holes and holes that predate holeMetadata — opens the editor. // manual holes and holes that predate holeMetadata — opens the editor.
if (metadata?.source === 'stair' && metadata.stairId) { if (metadata?.source === 'stair' && metadata.stairId) {
useEditor.getState().setEditingHole(null) useInteractionScope
.getState()
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
useEditor.getState().setHoveredHole(null) useEditor.getState().setHoveredHole(null)
resetPointerCursor() resetPointerCursor()
selectOwnedNode(metadata.stairId, 'stair') selectOwnedNode(metadata.stairId, 'stair')
return return
} }
if (metadata?.source === 'elevator' && metadata.elevatorId) { if (metadata?.source === 'elevator' && metadata.elevatorId) {
useEditor.getState().setEditingHole(null) useInteractionScope
.getState()
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
useEditor.getState().setHoveredHole(null) useEditor.getState().setHoveredHole(null)
resetPointerCursor() resetPointerCursor()
selectOwnedNode(metadata.elevatorId, 'elevator') selectOwnedNode(metadata.elevatorId, 'elevator')
return return
} }
useEditor.getState().setEditingHole({ nodeId: slabId, holeIndex }) useInteractionScope.getState().begin(holeEditScope({ nodeId: slabId, holeIndex }))
useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] }) useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] })
}, },
[holeIndex, metadata, slabId], [holeIndex, metadata, slabId],
@@ -6,10 +6,11 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides' import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import usePlacementPreview from '../../store/use-placement-preview' import usePlacementPreview from '../../store/use-placement-preview'
import useSegmentDraftChain from '../../store/use-segment-draft-chain' import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting' import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting'
type UseFloorplanBackgroundPlacementArgs = { type UseFloorplanBackgroundPlacementArgs = {
activePolygonDraftPoints: WallPlanPoint[] activePolygonDraftPoints: WallPlanPoint[]
@@ -42,7 +43,7 @@ type UseFloorplanBackgroundPlacementArgs = {
) => boolean ) => boolean
handleCeilingPlacementPoint: (point: WallPlanPoint) => void handleCeilingPlacementPoint: (point: WallPlanPoint) => void
handleSlabPlacementPoint: (point: WallPlanPoint) => void handleSlabPlacementPoint: (point: WallPlanPoint) => void
handleWallPlacementPoint: (point: WallPlanPoint, options?: { singleWall?: boolean }) => void handleWallPlacementPoint: (point: WallPlanPoint) => void
handleZonePlacementPoint: (point: WallPlanPoint) => void handleZonePlacementPoint: (point: WallPlanPoint) => void
isCeilingBuildActive: boolean isCeilingBuildActive: boolean
isCeilingItemPlacementActive: boolean isCeilingItemPlacementActive: boolean
@@ -61,7 +62,6 @@ type UseFloorplanBackgroundPlacementArgs = {
setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>> setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>> setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>> setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
shiftPressed: boolean
snapWallDraftPoint: (args: { snapWallDraftPoint: (args: {
point: WallPlanPoint point: WallPlanPoint
walls: WallNode[] walls: WallNode[]
@@ -75,7 +75,6 @@ type UseFloorplanBackgroundPlacementArgs = {
point: WallPlanPoint point: WallPlanPoint
start?: WallPlanPoint start?: WallPlanPoint
angleSnap: boolean angleSnap: boolean
bypassSnap?: boolean
}) => WallPlanPoint }) => WallPlanPoint
toPoint2D: (point: WallPlanPoint) => { x: number; y: number } toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
walls: WallNode[] walls: WallNode[]
@@ -122,7 +121,6 @@ export function useFloorplanBackgroundPlacement({
setFenceDraftStart, setFenceDraftStart,
setRoofDraftEnd, setRoofDraftEnd,
setRoofDraftStart, setRoofDraftStart,
shiftPressed,
snapWallDraftPoint, snapWallDraftPoint,
snapPolygonDraftPoint, snapPolygonDraftPoint,
toPoint2D, toPoint2D,
@@ -160,24 +158,21 @@ export function useFloorplanBackgroundPlacement({
} }
if (isCeilingBuildActive) { if (isCeilingBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey // Align the committed vertex the same way the move-preview did, so the
// Align the committed vertex the same way the move-preview did, so // placed point matches what the user saw — mode-driven (the chip):
// the placed point matches what the user saw. Wall magnetic snap may // `grid` quantizes, `angles` locks 15° rays, `lines` snaps onto walls /
// still win; generic alignment is skipped when angle snap owns the // alignment, `off` is free. Alt forces (skips alignment).
// vertex (matches the move branch). const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive()
const angleSnap = ceilingDraftPoints.length > 0 && !bypassSnap
const fallbackPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1], start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap, angleSnap,
bypassSnap,
}) })
const snappedPoint = resolveCeilingPlanPointSnap({ const snappedPoint = resolveCeilingPlanPointSnap({
rawPoint: planPoint, rawPoint: planPoint,
fallbackPoint, fallbackPoint,
levelId, levelId,
altKey: event.altKey, altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap, align: !angleSnap,
}).point }).point
@@ -187,11 +182,11 @@ export function useFloorplanBackgroundPlacement({
} }
if (isRoofBuildActive) { if (isRoofBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey // Footprint placement (polygon context: grid / lines / off, no angle),
const snappedPoint = alignFloorplanDraftPoint( // mode-driven to match the chip. Alt forces (skips alignment).
bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint), const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
{ bypass: event.altKey || bypassSnap }, bypass: event.altKey || !isMagneticSnapActive(),
) })
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
@@ -205,32 +200,30 @@ export function useFloorplanBackgroundPlacement({
} }
if (isFenceBuildActive) { if (isFenceBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey // Fence draft: mode-driven (matches the chip), same as the move
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then // preview. `grid` snaps to the world XZ grid (rotation-safe via the
// Figma alignment — endpoint snap wins (same precedence as move). // `gridSnap` callback), `angles` locks 15° rays from the start, `lines`
// While a draft is open the segment locks to 15° rays from its // pulls onto walls / fences / alignment, `off` is free.
// start unless Shift is held; Shift bypasses grid, magnetic, const fenceStep = getSegmentGridStep()
// angle, and alignment snap. `gridSnap` keeps the regular snap const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
// on the world XZ grid even when the building is rotated.
const fenceStep = WALL_GRID_STEP
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
const fenceSnapped = snapFenceDraftPoint({ const fenceSnapped = snapFenceDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
fences, fences,
start: fenceDraftStart ?? undefined, start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap, angleSnap: fenceAngleSnap,
bypassSnap, magnetic: isMagneticSnapActive(),
gridSnap: (p) => worldGridSnap(p, fenceStep), gridSnap: (p) => worldGridSnap(p, fenceStep),
}) })
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep) const fenceGridBase = worldGridSnap(planPoint, fenceStep)
const fenceLocked = const fenceLocked =
!bypassSnap && fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
const snappedPoint = const snappedPoint =
fenceLocked || fenceAngleSnap fenceLocked || fenceAngleSnap
? fenceSnapped ? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap }) : alignFloorplanDraftPoint(fenceSnapped, {
bypass: !isMagneticSnapActive(),
})
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
@@ -249,6 +242,14 @@ export function useFloorplanBackgroundPlacement({
} else if ( } else if (
getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01 getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01
) { ) {
// Single mode commits one segment per click: the same emit above
// already made the 3D fence tool stopDrafting, so close the 2D
// draft too instead of chaining.
if (useEditor.getState().getContinuation('fence') === 'single') {
clearFencePlacementDraft()
setCursorPoint(snappedPoint)
return true
}
// The 3D fence tool owns creation and keeps chaining from the // The 3D fence tool owns creation and keeps chaining from the
// committed fence's resolved end — chain the 2D draft from the // committed fence's resolved end — chain the 2D draft from the
// same published point so both views draft the next segment // same published point so both views draft the next segment
@@ -268,13 +269,11 @@ export function useFloorplanBackgroundPlacement({
// swallow the click and skip local draft state updates — leaving // swallow the click and skip local draft state updates — leaving
// the 2D draft polygon invisible while the 3D tool builds fine). // the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) { if (isPolygonBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive()
const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap
const fallbackPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap, angleSnap,
bypassSnap,
}) })
let snappedPoint = fallbackPoint let snappedPoint = fallbackPoint
if (isSlabBuildActive) { if (isSlabBuildActive) {
@@ -283,12 +282,11 @@ export function useFloorplanBackgroundPlacement({
fallbackPoint, fallbackPoint,
levelId, levelId,
altKey: event.altKey, altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap, align: !angleSnap,
}).point }).point
} else if (!angleSnap) { } else if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
bypass: event.altKey || bypassSnap, bypass: event.altKey || !isMagneticSnapActive(),
}) })
} }
@@ -313,34 +311,31 @@ export function useFloorplanBackgroundPlacement({
// / draftEnd state in the floor plan would never update, leaving // / draftEnd state in the floor plan would never update, leaving
// the dashed-line draft preview invisible. // the dashed-line draft preview invisible.
if (isWallBuildActive) { if (isWallBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey // Wall draft: mode-driven (matches the chip + the move-preview branch).
// Wall draft: grid snap (+ existing-wall endpoint/join snap), then // `grid` snaps to the world XZ grid (rotation-safe via `gridSnap`),
// Figma alignment — endpoint/join snap wins (same precedence as the // `angles` locks 15° rays from the start, `lines` pulls the endpoint
// move-preview branch), so committing onto a corner still works. // onto existing wall corners / edges + alignment, `off` is free.
// While a draft is open the segment locks to 15° rays from its const wallStep = getSegmentGridStep()
// start unless Shift is held; Shift bypasses grid, magnetic, const wallAngleSnap = draftStart !== null && isAngleSnapActive()
// 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 wallSnapped = snapWallDraftPoint({ const wallSnapped = snapWallDraftPoint({
point: planPoint, point: planPoint,
walls, walls,
start: draftStart ?? undefined, start: draftStart ?? undefined,
angleSnap: wallAngleSnap, angleSnap: wallAngleSnap,
bypassSnap,
gridSnap: (p) => worldGridSnap(p, wallStep), gridSnap: (p) => worldGridSnap(p, wallStep),
}) })
const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep) const wallGridBase = worldGridSnap(planPoint, wallStep)
const wallLocked = const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]
!bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
let snappedPoint = wallSnapped let snappedPoint = wallSnapped
if (wallLocked) { if (wallLocked) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} else { } else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, { snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap, applySnap: !wallAngleSnap,
bypass: event.altKey || bypassSnap, // Figma alignment pulls the endpoint onto existing wall corners /
// edges, so it is a line snap — suppress it whenever magnetic snap
// is off (`'off'` / `'angles'`), matching the wall-geometry snap.
bypass: !isMagneticSnapActive(),
}) })
} }
@@ -356,7 +351,7 @@ export function useFloorplanBackgroundPlacement({
return true return true
} }
handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey }) handleWallPlacementPoint(snappedPoint)
return true return true
} }
@@ -414,7 +409,6 @@ export function useFloorplanBackgroundPlacement({
setFenceDraftStart, setFenceDraftStart,
setRoofDraftEnd, setRoofDraftEnd,
setRoofDraftStart, setRoofDraftStart,
shiftPressed,
snapWallDraftPoint, snapWallDraftPoint,
snapPolygonDraftPoint, snapPolygonDraftPoint,
toPoint2D, toPoint2D,
@@ -32,8 +32,14 @@ import {
} from 'three' } from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { endpointReshapeScope } from '../../lib/interaction/scope'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import useInteractionScope, {
useEndpointReshape,
useIsCurveReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { import {
createArrowHitAreaGeometry, createArrowHitAreaGeometry,
@@ -118,11 +124,9 @@ export function WallMoveSideHandles() {
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useMovingNode()
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) const endpointReshape = useEndpointReshape()
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const isCurveReshape = useIsCurveReshape()
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
// Fence side-move / height / corner-pickers now flow through the // Fence side-move / height / corner-pickers now flow through the
@@ -140,10 +144,8 @@ export function WallMoveSideHandles() {
!isFloorplanHovered && !isFloorplanHovered &&
mode !== 'delete' && mode !== 'delete' &&
!movingNode && !movingNode &&
!movingWallEndpoint && !endpointReshape &&
!movingFenceEndpoint && !isCurveReshape
!curvingWall &&
!curvingFence
if (!shouldRender || !selectedNode) return null if (!shouldRender || !selectedNode) return null
@@ -333,7 +335,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
suppressBoxSelectForPointer(event) suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
document.body.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing'
useEditor.getState().setMovingWallEndpoint({ wall, endpoint }) useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint))
} }
return ( return (
@@ -468,7 +470,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
document.body.style.cursor = 'ns-resize' document.body.style.cursor = 'ns-resize'
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setActiveHandleDrag({ nodeId: wallId, label: 'height' }) useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' })
// Suppress R3F node pointer events until pointerup completes so the // Suppress R3F node pointer events until pointerup completes so the
// synthesized click doesn't reroute selection to whatever mesh sits // synthesized click doesn't reroute selection to whatever mesh sits
// under the cursor at release. // under the cursor at release.
@@ -498,7 +500,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
document.body.style.cursor = '' document.body.style.cursor = ''
} }
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useEditor.getState().setActiveHandleDrag(null) useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
useViewer.getState().setInputDragging(false) useViewer.getState().setInputDragging(false)
dragCleanupRef.current = null dragCleanupRef.current = null
} }
@@ -611,10 +613,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(wall) useEditor.getState().setMovingNode(wall)
useEditor.getState().setMovingWallEndpoint(null) useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
useEditor.getState().setMovingFenceEndpoint(null) useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
// Keep the wall selected so it stays the active item once the move // Keep the wall selected so it stays the active item once the move
// commits; the `!movingNode` guard on the handles hides them mid-drag. // commits; the `!movingNode` guard on the handles hides them mid-drag.
} }
@@ -702,10 +702,8 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(fence) useEditor.getState().setMovingNode(fence)
useEditor.getState().setMovingWallEndpoint(null) useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
useEditor.getState().setMovingFenceEndpoint(null) useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
// Keep the fence selected so it stays active once the move commits. // Keep the fence selected so it stays active once the move commits.
} }
@@ -19,6 +19,10 @@ import {
} from '../../../lib/ceiling-plan-snap' } from '../../../lib/ceiling-plan-snap'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useInteractionScope, {
useIsCurveReshape,
useMovingNode,
} from '../../../store/use-interaction-scope'
import { snapToHalf } from '../../tools/item/placement-math' import { snapToHalf } from '../../tools/item/placement-math'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
@@ -95,8 +99,8 @@ export const CeilingSelectionAffordanceSystem = () => {
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const structureLayer = useEditor((state) => state.structureLayer) const structureLayer = useEditor((state) => state.structureLayer)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useMovingNode()
const curvingWall = useEditor((state) => state.curvingWall) const isCurveReshape = useIsCurveReshape()
const currentLevelId = useViewer((state) => state.selection.levelId) const currentLevelId = useViewer((state) => state.selection.levelId)
const ceilings = useScene( const ceilings = useScene(
@@ -117,7 +121,7 @@ export const CeilingSelectionAffordanceSystem = () => {
mode === 'select' && mode === 'select' &&
structureLayer === 'elements' && structureLayer === 'elements' &&
!movingNode && !movingNode &&
!curvingWall && !isCurveReshape &&
currentLevelId !== null currentLevelId !== null
if (!shouldRender) return null if (!shouldRender) return null
@@ -196,9 +200,11 @@ const CeilingSelectionAffordance = ({
const selectCeilingForEdit = useCallback(() => { const selectCeilingForEdit = useCallback(() => {
const editor = useEditor.getState() const editor = useEditor.getState()
editor.setMovingNode(null) editor.setMovingNode(null)
editor.setMovingWallEndpoint(null) useInteractionScope
editor.setCurvingWall(null) .getState()
editor.setEditingHole(null) .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint')
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve')
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
editor.setMode('select') editor.setMode('select')
useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] }) useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] })
}, [effectiveCeiling.id]) }, [effectiveCeiling.id])
@@ -483,9 +489,11 @@ const CornerBracket = ({
e.stopPropagation() e.stopPropagation()
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
useEditor.getState().setMovingWallEndpoint(null) useInteractionScope
useEditor.getState().setCurvingWall(null) .getState()
useEditor.getState().setEditingHole(null) .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint')
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve')
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
useEditor.getState().setMode('select') useEditor.getState().setMode('select')
emitter.emit('ceiling:click' as any, { emitter.emit('ceiling:click' as any, {
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Color, type Material, type Mesh } from 'three' import { Color, type Material, type Mesh } from 'three'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { useMovingNode } from '../../../store/use-interaction-scope'
const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff' const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff'
const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial' const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial'
@@ -75,7 +76,7 @@ function setCeilingGridHighlighted(ceilingGrid: Mesh, highlighted: boolean) {
export const CeilingSystem = () => { export const CeilingSystem = () => {
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const selectedItem = useEditor((state) => state.selectedItem) const selectedItem = useEditor((state) => state.selectedItem)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useMovingNode()
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const activeLevelId = useViewer((state) => state.selection.levelId) const activeLevelId = useViewer((state) => state.selection.levelId)
const hoveredId = useViewer((state) => state.hoveredId) const hoveredId = useViewer((state) => state.hoveredId)
@@ -6,8 +6,10 @@ import { Check, Pencil } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
// ─── Per-zone label editor ──────────────────────────────────────────────────── // ─── Per-zone label editor ────────────────────────────────────────────────────
@@ -19,6 +21,10 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
const selectedZoneId = useViewer((s) => s.selection.zoneId) const selectedZoneId = useViewer((s) => s.selection.zoneId)
const hoveredId = useViewer((s) => s.hoveredId) const hoveredId = useViewer((s) => s.hoveredId)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
// During an active interaction the zone label is a context badge that steps
// back: faded + non-interactive so it can't be hovered/clicked mid-action.
const scope = useInteractionScope((s) => s.scope)
const labelStepBack = resolveOverlayPolicy(scope).contextBadges === 'faded'
const isSelected = selectedZoneId === zoneId const isSelected = selectedZoneId === zoneId
const isDeleteHovered = mode === 'delete' && hoveredId === zoneId const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
@@ -149,7 +155,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
fontSize: 14, fontSize: 14,
fontFamily: 'sans-serif', fontFamily: 'sans-serif',
userSelect: 'none', userSelect: 'none',
pointerEvents: 'auto', pointerEvents: labelStepBack ? 'none' : 'auto',
opacity: labelStepBack ? 0.4 : undefined,
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 4,
@@ -3,7 +3,9 @@ import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { type Group, MathUtils, type Mesh } from 'three' import { type Group, MathUtils, type Mesh } from 'three'
import type { MeshBasicNodeMaterial } from 'three/webgpu' import type { MeshBasicNodeMaterial } from 'three/webgpu'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
// Disable raycasting on zone geometry so clicks pass through to items underneath. // Disable raycasting on zone geometry so clicks pass through to items underneath.
// Zone selection in the editor is handled exclusively via the HTML label overlay. // Zone selection in the editor is handled exclusively via the HTML label overlay.
@@ -20,6 +22,11 @@ export const ZoneSystem = () => {
// geometry or the HTML zone tags in the framed shot. // geometry or the HTML zone tags in the framed shot.
const isCaptureMode = useEditor.getState().isCaptureMode const isCaptureMode = useEditor.getState().isCaptureMode
// During any active interaction zone labels step back entirely — they are
// not a primary editing concern and would distract / invite misclicks.
const zoneLabelsHidden =
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
const zoneGeometryVisible = structureLayer === 'zones' const zoneGeometryVisible = structureLayer === 'zones'
const zones = sceneRegistry.byType.zone || new Set() const zones = sceneRegistry.byType.zone || new Set()
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
@@ -84,7 +91,8 @@ export const ZoneSystem = () => {
// Labels: visible on the current level (regardless of mode), but never // Labels: visible on the current level (regardless of mode), but never
// during snapshot capture. // during snapshot capture.
const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel const showLabel =
!isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
const labelOpacity = showLabel ? '1' : '0' const labelOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${zoneId}-label`) const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== labelOpacity) { if (labelEl && labelEl.style.opacity !== labelOpacity) {
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support' import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import usePlacementPreview from '../../../store/use-placement-preview' import usePlacementPreview from '../../../store/use-placement-preview'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { import {
@@ -163,7 +164,8 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
// point: resolving against the grid point would only ever catch anchors // point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled // that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the // walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses. // candidate's coordinate; the other axis keeps its grid snap. Alignment runs
// only when the magnetic (lines) snapping mode is active.
const alignPoint = ( const alignPoint = (
gridX: number, gridX: number,
gridZ: number, gridZ: number,
@@ -195,13 +197,19 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true // Grid snap follows the global mode (live step so the HUD chip is
// honest); Off keeps the raw cursor. Shift cycles the mode centrally.
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, isGridSnapActive()
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, ? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
const supportY = resolveElevatorSupportY({ const supportY = resolveElevatorSupportY({
buildingId: currentBuildingId, buildingId: currentBuildingId,
@@ -221,7 +229,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
}) })
if ( if (
!bypassSnap && (isGridSnapActive() || isMagneticSnapActive()) &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) { ) {
@@ -239,13 +247,17 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
}) })
if (!latestBuildingId) return if (!latestBuildingId) return
const bypassSnap = event.nativeEvent?.shiftKey === true const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, isGridSnapActive()
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, ? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
commitElevatorPlacement( commitElevatorPlacement(
latestBuildingId, latestBuildingId,
@@ -1,7 +1,7 @@
import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core' import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
import { nodeRegistry } from '@pascal-app/core' import { nodeRegistry } from '@pascal-app/core'
import { Suspense } from 'react' import { Suspense } from 'react'
import useEditor from '../../../store/use-editor' import { useMovingNode } from '../../../store/use-interaction-scope'
import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool'
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
@@ -27,7 +27,7 @@ export const MoveTool: React.FC<{
onNodeMoved?: (nodeId: AnyNodeId) => void onNodeMoved?: (nodeId: AnyNodeId) => void
onSpawnMoved?: (nodeId: SpawnNode['id']) => void onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onNodeMoved }) => { }> = ({ onNodeMoved }) => {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useMovingNode()
if (!movingNode) return null if (!movingNode) return null
@@ -1,9 +1,26 @@
import { type AssetInput, isObject } from '@pascal-app/core' import { type AssetInput, isObject } from '@pascal-app/core'
import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
import useEditor from '../../../store/use-editor' import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { getActiveSnappingMode } from '../../../store/use-editor'
// Sentinel returned when the active context's snapping mode disables grid snap.
// The snap helpers below treat any `step <= 0` as "no grid snap" and pass the
// raw value through. For items the default mode is now `lines` (grid off), so
// item placement/move is free + line-snap unless the user opts into `grid`.
function getGridSnapStep(): number { function getGridSnapStep(): number {
return useEditor.getState().gridSnapStep return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0
}
const ROTATION_QUANTUM = Math.PI / 4
/**
* R/T rotation: round the current angle to the nearest 45° then step ONE
* increment in `direction` (+1 / -1), so the node always lands on a clean 45°
* multiple regardless of its starting angle (12° → 45°, 40° → 90°) rather than a
* blind ±45° from an arbitrary angle.
*/
export function steppedRotation(current: number, direction: 1 | -1): number {
return (Math.round(current / ROTATION_QUANTUM) + direction) * ROTATION_QUANTUM
} }
function positiveModulo(value: number, divisor: number): number { function positiveModulo(value: number, divisor: number): number {
@@ -14,6 +31,7 @@ function positiveModulo(value: number, divisor: number): number {
* Snaps a position to the active grid step, aligning item edges to grid lines. * Snaps a position to the active grid step, aligning item edges to grid lines.
*/ */
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number { export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
if (step <= 0) return position
const halfDim = dimension / 2 const halfDim = dimension / 2
const offset = positiveModulo(halfDim, step) const offset = positiveModulo(halfDim, step)
return Math.round((position - offset) / step) * step + offset return Math.round((position - offset) / step) * step + offset
@@ -23,6 +41,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
* Snap a value to the active grid step (used for wall-local positions). * Snap a value to the active grid step (used for wall-local positions).
*/ */
export function snapToHalf(value: number, step = getGridSnapStep()): number { export function snapToHalf(value: number, step = getGridSnapStep()): number {
if (step <= 0) return value
return Math.round(value / step) * step return Math.round(value / step) * step
} }
@@ -30,6 +49,7 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
* Round a value up to the next multiple of `step`, with a minimum of `step`. * Round a value up to the next multiple of `step`, with a minimum of `step`.
*/ */
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number { export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
if (step <= 0) return value
return Math.max(step, Math.ceil(value / step) * step) return Math.max(step, Math.ceil(value / step) * step)
} }
@@ -16,6 +16,7 @@ import type {
WallNode, WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
canHostOnTop,
clampRectToRoofWallFace, clampRectToRoofWallFace,
getRoofSegmentWallFace, getRoofSegmentWallFace,
getScaledDimensions, getScaledDimensions,
@@ -64,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
} }
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) { function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
if (!canHostOnTop(surfaceItem)) return null
if (isLowProfileItemSurface(surfaceItem)) return null if (isLowProfileItemSurface(surfaceItem)) return null
if (!isUpwardItemSurfaceHit(event)) return null if (!isUpwardItemSurfaceHit(event)) return null
@@ -113,10 +115,9 @@ export const floorStrategy = {
// is rotated; then project the world point back into building-local // is rotated; then project the world point back into building-local
// for storage. Without this, a rotated building drags placement off // for storage. Without this, a rotated building drags placement off
// the world grid. // the world grid.
const bypassSnap = event.nativeEvent?.shiftKey === true // Snapping is governed by the active mode (snapToGrid returns raw in Off /
const [x, z] = bypassSnap // non-grid modes); Alt is force-place only and never bypasses snapping here.
? [event.localPosition[0], event.localPosition[2]] const [x, z] = snapWorldXZForActiveBuilding(
: snapWorldXZForActiveBuilding(
snapToGrid(event.position[0], swapDims ? dimZ : dimX), snapToGrid(event.position[0], swapDims ? dimZ : dimX),
snapToGrid(event.position[2], swapDims ? dimX : dimZ), snapToGrid(event.position[2], swapDims ? dimX : dimZ),
0, 0,
@@ -202,10 +203,9 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const bypassSnap = event.nativeEvent?.shiftKey === true const x = snapToHalf(event.localPosition[0])
const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) const y = snapToHalf(event.localPosition[1])
const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) const z = snapToHalf(event.localPosition[2])
const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator // Get auto-adjusted Y position from validator
const rawDims = ctx.draftItem const rawDims = ctx.draftItem
@@ -237,9 +237,7 @@ export const wallStrategy = {
}, },
cursorRotationY: cursorRotation, cursorRotationY: cursorRotation,
gridPosition: [x, adjustedY, z], gridPosition: [x, adjustedY, z],
cursorPosition: bypassSnap cursorPosition: [
? [event.position[0], event.position[1], event.position[2]]
: [
snapToHalf(event.position[0]), snapToHalf(event.position[0]),
snapToHalf(event.position[1]), snapToHalf(event.position[1]),
snapToHalf(event.position[2]), snapToHalf(event.position[2]),
@@ -266,10 +264,9 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal) const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const bypassSnap = event.nativeEvent?.shiftKey === true const snappedX = snapToHalf(event.localPosition[0])
const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) const snappedY = snapToHalf(event.localPosition[1])
const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) const snappedZ = snapToHalf(event.localPosition[2])
const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator // Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall( const validation = validators.canPlaceOnWall(
@@ -287,9 +284,7 @@ export const wallStrategy = {
return { return {
gridPosition: [snappedX, adjustedY, snappedZ], gridPosition: [snappedX, adjustedY, snappedZ],
cursorPosition: bypassSnap cursorPosition: [
? [event.position[0], event.position[1], event.position[2]]
: [
snapToHalf(event.position[0]), snapToHalf(event.position[0]),
snapToHalf(event.position[1]), snapToHalf(event.position[1]),
snapToHalf(event.position[2]), snapToHalf(event.position[2]),
@@ -393,14 +388,14 @@ type RoofWallTarget = {
* `wall-side` items mount on the outer surface, `wall` items center in * `wall-side` items mount on the outer surface, `wall` items center in
* the wall thickness. * the wall thickness.
* *
* `shiftFree` mirrors the wall flow's Shift override (stubbed * `freePlace` mirrors the wall flow's Alt override (stubbed
* validators): the profile clamp is skipped, so the rect may overhang * validators): the profile clamp is skipped, so the rect may overhang
* the face edges — placement follows the snapped cursor as-is. * the face edges — placement follows the snapped cursor as-is.
*/ */
function resolveRoofWallTarget( function resolveRoofWallTarget(
ctx: PlacementContext, ctx: PlacementContext,
event: RoofEvent, event: RoofEvent,
shiftFree = false, freePlace = false,
): RoofWallTarget | null { ): RoofWallTarget | null {
const attachTo = ctx.asset.attachTo const attachTo = ctx.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
@@ -414,10 +409,12 @@ function resolveRoofWallTarget(
const dims = getGridAlignedDimensions(rawDims, attachTo) const dims = getGridAlignedDimensions(rawDims, attachTo)
const [width, height] = dims const [width, height] = dims
const u = shiftFree ? hit.u : snapToHalf(hit.u) // Snap follows the active mode (snapToHalf returns raw in Off/non-grid);
const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2 // `freePlace` (Alt) is force-place — it only skips the face-fit validity gate.
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height) const u = snapToHalf(hit.u)
if (!fitted && !shiftFree) return null const centerV = 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 finalU = fitted?.u ?? u
const finalV = fitted?.v ?? centerV const finalV = fitted?.v ?? centerV
@@ -483,8 +480,8 @@ export const roofWallStrategy = {
* face. Returns null when the item doesn't wall-attach or the pointer * face. Returns null when the item doesn't wall-attach or the pointer
* isn't over a placeable face. * isn't over a placeable face.
*/ */
enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null { enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null {
const target = resolveRoofWallTarget(ctx, event, shiftFree) const target = resolveRoofWallTarget(ctx, event, freePlace)
if (!target) return null if (!target) return null
return { return {
@@ -511,11 +508,11 @@ export const roofWallStrategy = {
* segment transitions inside one roof never re-fire roof:enter) or to * segment transitions inside one roof never re-fire roof:enter) or to
* no placeable face. * no placeable face.
*/ */
move(ctx: PlacementContext, event: RoofEvent, shiftFree = false): PlacementResult | null { move(ctx: PlacementContext, event: RoofEvent, freePlace = false): PlacementResult | null {
if (ctx.state.surface !== 'roof-wall') return null if (ctx.state.surface !== 'roof-wall') return null
if (!ctx.draftItem) return null if (!ctx.draftItem) return null
const target = resolveRoofWallTarget(ctx, event, shiftFree) const target = resolveRoofWallTarget(ctx, event, freePlace)
if (!target) return null if (!target) return null
if (target.segment.id !== ctx.state.roofSegmentId) return null if (target.segment.id !== ctx.state.roofSegmentId) return null
@@ -538,12 +535,12 @@ export const roofWallStrategy = {
/** /**
* Handle roof:click — commit placement on the segment wall face. * Handle roof:click — commit placement on the segment wall face.
*/ */
click(ctx: PlacementContext, _event: RoofEvent, shiftFree = false): CommitResult | null { click(ctx: PlacementContext, _event: RoofEvent, freePlace = false): CommitResult | null {
if (ctx.state.surface !== 'roof-wall') return null if (ctx.state.surface !== 'roof-wall') return null
if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
// Shift mirrors the wall flow's stubbed validators: skip profile-fit // Alt mirrors the wall flow's stubbed validators: skip profile-fit
// and overlap checks entirely. // and overlap checks entirely.
if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
return { return {
nodeUpdate: { nodeUpdate: {
@@ -615,13 +612,8 @@ export const ceilingStrategy = {
// Ceiling items are stored in ceiling-local coordinates, so snapping must // Ceiling items are stored in ceiling-local coordinates, so snapping must
// use the ceiling hit's local position rather than world position. // use the ceiling hit's local position rather than world position.
const bypassSnap = event.nativeEvent?.shiftKey === true const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const x = bypassSnap const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = bypassSnap
? event.localPosition[2]
: snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
// Recessed fixtures seat flush with the ceiling plane (body rising into the // Recessed fixtures seat flush with the ceiling plane (body rising into the
// void above); everything else hangs its full height below the ceiling. // void above); everything else hangs its full height below the ceiling.
const seatY = ctx.asset.recessed ? 0 : -itemHeight const seatY = ctx.asset.recessed ? 0 : -itemHeight
@@ -654,13 +646,8 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem.rotation?.[1] ?? 0 const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9 const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const bypassSnap = event.nativeEvent?.shiftKey === true const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const x = bypassSnap const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = bypassSnap
? event.localPosition[2]
: snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
// Recessed fixtures seat flush with the ceiling plane (body rising into the // Recessed fixtures seat flush with the ceiling plane (body rising into the
// void above); everything else hangs its full height below the ceiling. // void above); everything else hangs its full height below the ceiling.
const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
@@ -771,9 +758,8 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null if (surfaceHeight === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true const x = snapToGrid(localPos.x, ourDims[0])
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -823,9 +809,8 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null if (surfaceHeight === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true const x = snapToGrid(localPos.x, ourDims[0])
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -924,9 +909,8 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null if (rowY === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true const x = snapToGrid(localPos.x, ourDims[0])
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
@@ -969,9 +953,8 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null if (rowY === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true const x = snapToGrid(localPos.x, ourDims[0])
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
return { return {
@@ -40,11 +40,16 @@ import {
} from 'three' } from 'three'
import { distance, smoothstep, uv, vec2 } from 'three/tsl' import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import {
clearPlacementSurface,
publishPlacementSurface,
} from '../../../lib/active-placement-surface'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { formatLinearMeasurement } from '../../../lib/measurements' import { formatLinearMeasurement } from '../../../lib/measurements'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap' import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
import useEditor from '../../../store/use-editor' import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
createLineGeometry, createLineGeometry,
@@ -56,7 +61,9 @@ import {
getDetachedAttachmentPreviewLift, getDetachedAttachmentPreviewLift,
getGridAlignedDimensions, getGridAlignedDimensions,
snapToGrid, snapToGrid,
snapToHalf,
snapUpToGridStep, snapUpToGridStep,
steppedRotation,
} from './placement-math' } from './placement-math'
import { import {
ceilingStrategy, ceilingStrategy,
@@ -76,6 +83,13 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
* floor-plan overlay and the 3D registry move tool. */ * floor-plan overlay and the 3D registry move tool. */
const ALIGNMENT_THRESHOLD_M = 0.08 const ALIGNMENT_THRESHOLD_M = 0.08
/** Right-click cancels an active placement — but the right button also orbits
* the camera (CameraControls ROTATE). Only a quick, near-stationary right
* press/release counts as a cancel; anything that moves past the pixel
* threshold or is held longer is treated as a camera orbit and left alone. */
const RIGHT_CLICK_CANCEL_MAX_MOVE_PX = 4
const RIGHT_CLICK_CANCEL_MAX_MS = 200
/** /**
* Expand `bounds` outward so each axis is rounded up to the active grid step. * Expand `bounds` outward so each axis is rounded up to the active grid step.
* The wireframe stays centered on the original bounds centre on each axis we * The wireframe stays centered on the original bounds centre on each axis we
@@ -221,8 +235,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
shelfId: null, shelfId: null,
}, },
) )
const shiftFreeRef = useRef(false) const altFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null) const previewBoundsSignatureRef = useRef<string | null>(null)
// Footprint shape (depth along local +Z and [x, z] centre) of the current
// preview box, mirrored from the rendered dimension bounds so the per-frame
// surface publisher can position the forward-facing triangle without reading
// React state. Updated in the render body below.
const facingShapeRef = useRef<{ depth: number; center: [number, number] }>({
depth: 0,
center: [0, 0],
})
// Goes true the first time a 3D pointer event drives this coordinator. // Goes true the first time a 3D pointer event drives this coordinator.
// The per-frame mesh-position lerp below is only useful for that path; // The per-frame mesh-position lerp below is only useful for that path;
// when the move is being driven externally (2D `FloorplanRegistryMoveOverlay` // when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
@@ -441,7 +463,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}) })
const getActiveValidators = () => const getActiveValidators = () =>
shiftFreeRef.current altFreeRef.current
? { ? {
canPlaceOnFloor: () => ({ valid: true }), canPlaceOnFloor: () => ({ valid: true }),
canPlaceOnWall: () => ({ valid: true }), canPlaceOnWall: () => ({ valid: true }),
@@ -449,8 +471,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
: validators : validators
const finishCommittedPlacement = (
committedId: string | null,
wasAdopted: boolean,
repeat: () => void,
) => {
if (configRef.current.onCommitted()) {
repeat()
return
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
if (committedId) {
useViewer.getState().setSelection({ selectedIds: [committedId as AnyNodeId] })
}
if (!wasAdopted) {
useEditor.getState().setTool(null)
}
// A non-repeating placement is finished: return to select mode so the user
// lands on the just-placed (now selected) node instead of a tool-less build
// limbo. Repeat placements took the early return above and stay armed.
useEditor.getState().setMode('select')
}
const revalidate = (): boolean => { const revalidate = (): boolean => {
const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators) const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500 const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
edgeMaterial.color.setHex(color) edgeMaterial.color.setHex(color)
basePlaneMaterial.color.setHex(color) basePlaneMaterial.color.setHex(color)
@@ -608,10 +654,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
// Floor grab-offset: the item tracks the grabbed point instead of snapping // Floor grab-offset: the item tracks the grabbed point instead of snapping
// its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid // its origin under the cursor. The offset is computed in building-local space
// (`event.position`) on its default path and only reads `event.localPosition` // (`event.localPosition`), but `floorStrategy.move` snaps on the WORLD grid
// under Shift, so both frames must carry the offset; the world point is // (`event.position`), so the corrected local point is re-projected to a
// derived from the corrected local one so the two stay consistent. // corrected world point and both frames carry the offset to stay consistent.
const applyFloorGrabOffset = (event: GridEvent): GridEvent => { const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
if (relativeFloorStart === null) return event if (relativeFloorStart === null) return event
const rawX = event.localPosition[0] const rawX = event.localPosition[0]
@@ -773,12 +819,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// item's edge, snap and publish a guide. The guide connects to the // item's edge, snap and publish a guide. The guide connects to the
// nearest real corner of the candidate (resolver tie-break), so the dot // nearest real corner of the candidate (resolver tie-break), so the dot
// always sits on an actual point. The delta is applied to BOTH the grid // always sits on an actual point. The delta is applied to BOTH the grid
// and cursor positions below. Alt bypasses alignment; Shift bypasses all snap. // and cursor positions below. Alt is force-place only (it does NOT bypass
// snapping — 'off' mode is the no-snap bypass); the active snapping mode
// governs whether alignment runs at all ('off' / 'angles' disable
// magnetic alignment, 'lines' enables it, matching the wall/fence flow).
const draft = draftNode.current const draft = draftNode.current
let alignX = 0 let alignX = 0
let alignZ = 0 let alignZ = 0
const bypassSnap = floorEvent.nativeEvent?.shiftKey === true // Alignment ("lines") follows the snapping mode only — Alt is force-place,
const bypassAlign = floorEvent.nativeEvent?.altKey === true || bypassSnap // it does NOT bypass snapping (Off mode is the no-snap bypass).
const bypassAlign = !isMagneticSnapActive()
if (!bypassAlign && draft) { if (!bypassAlign && draft) {
alignmentCandidates ??= collectAlignmentAnchors( alignmentCandidates ??= collectAlignmentAnchors(
useScene.getState().nodes, useScene.getState().nodes,
@@ -812,7 +862,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Play snap sound when grid position changes // Play snap sound when grid position changes
if ( if (
!bypassSnap &&
previousGridPos && previousGridPos &&
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2]) (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
) { ) {
@@ -864,8 +913,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
if (configRef.current.onCommitted()) { const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
draftNode.create( draftNode.create(
gridPosition.current, gridPosition.current,
asset, asset,
@@ -881,7 +932,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
updatePreviewGeometry(previewBounds) updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds) updateDimensionGuides(previewBounds)
revalidate() revalidate()
} })
} }
// ---- Wall Handlers ---- // ---- Wall Handlers ----
@@ -997,7 +1048,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
// Play snap sound when grid position changes // Play snap sound when grid position changes
if (event.nativeEvent?.shiftKey !== true && posChanged) { if (posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1060,12 +1111,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (result.dirtyNodeId) { if (result.dirtyNodeId) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId) useScene.getState().dirtyNodes.add(result.dirtyNodeId)
} }
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter( const enterResult = wallStrategy.enter(
getContext(), getContext(),
@@ -1079,7 +1132,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else { } else {
revalidate() revalidate()
} }
} })
} }
const onWallLeave = (event: WallEvent) => { const onWallLeave = (event: WallEvent) => {
@@ -1121,7 +1174,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// re-enters whenever the strategy reports a segment change. // re-enters whenever the strategy reports a segment change.
const enterRoofWall = (event: RoofEvent): boolean => { const enterRoofWall = (event: RoofEvent): boolean => {
const result = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current) const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
if (!result) return false if (!result) return false
event.stopPropagation() event.stopPropagation()
@@ -1152,7 +1205,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return return
} }
const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current) const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
if (!result) { if (!result) {
// Different segment under the pointer (or no placeable face) — // Different segment under the pointer (or no placeable face) —
// try a fresh enter; a null resolve leaves the draft where it is. // try a fresh enter; a null resolve leaves the draft where it is.
@@ -1167,7 +1220,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
if (!shiftFreeRef.current && posChanged) { if (posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1210,23 +1263,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onRoofWallClick = (event: RoofEvent) => { const onRoofWallClick = (event: RoofEvent) => {
const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current) const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
if (!result) return if (!result) return
event.stopPropagation() event.stopPropagation()
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current) const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
revalidate() revalidate()
} }
} })
} }
const onRoofWallLeave = (event: RoofEvent) => { const onRoofWallLeave = (event: RoofEvent) => {
@@ -1261,9 +1316,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.position[1], event.position[1],
event.position[2], event.position[2],
) )
const bypassSnap = event.nativeEvent?.shiftKey === true // Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass.
const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2 const wx = snapToHalf(buildingLocalPoint.x)
const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2 const wz = snapToHalf(buildingLocalPoint.z)
const floorPos: [number, number, number] = [wx, 0, wz] const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { Object.assign(placementState.current, {
@@ -1426,15 +1481,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
if (configRef.current.onCommitted()) { const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never) const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
revalidate() revalidate()
} }
} })
return return
} }
} }
@@ -1452,15 +1509,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
if (configRef.current.onCommitted()) { const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic) const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
revalidate() revalidate()
} }
} })
return return
} }
} }
@@ -1481,8 +1540,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
if (configRef.current.onCommitted()) { const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter( const enterResult = ceilingStrategy.enter(
getContext(), getContext(),
@@ -1495,7 +1556,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else { } else {
revalidate() revalidate()
} }
} })
return return
} }
} }
@@ -1511,9 +1572,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
// Try to set up next draft on the same surface // Try to set up next draft on the same surface
const enterResult = itemSurfaceStrategy.enter(getContext(), event) const enterResult = itemSurfaceStrategy.enter(getContext(), event)
if (enterResult) { if (enterResult) {
@@ -1521,7 +1584,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else { } else {
revalidate() revalidate()
} }
} })
} }
// ---- Ceiling Handlers ---- // ---- Ceiling Handlers ----
@@ -1598,7 +1661,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
if (event.nativeEvent?.shiftKey !== true && posChanged) { if (posChanged) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -1637,9 +1700,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (enterResult) { if (enterResult) {
@@ -1647,7 +1712,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else { } else {
revalidate() revalidate()
} }
} })
} }
const onCeilingLeave = (event: CeilingEvent) => { const onCeilingLeave = (event: CeilingEvent) => {
@@ -1775,26 +1840,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id) useLiveTransforms.getState().clear(draftNode.current.id)
} }
draftNode.commit(result.nodeUpdate) const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = shelfSurfaceStrategy.enter(getContext(), event) const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
if (enterResult) { if (enterResult) {
applyTransition(enterResult) applyTransition(enterResult)
} else { } else {
revalidate() revalidate()
} }
} })
} }
// ---- Keyboard rotation ---- // ---- Keyboard rotation ----
// 45° increments — matches the R-key rotation step for already-placed
// items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
const ROTATION_STEP = Math.PI / 4
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') { if (event.key === 'Alt') {
shiftFreeRef.current = true altFreeRef.current = true
revalidate() revalidate()
return return
} }
@@ -1811,17 +1875,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// manual rotation would skew them off the wall plane. // manual rotation would skew them off the wall plane.
if (placementState.current.surface === 'roof-wall') return if (placementState.current.surface === 'roof-wall') return
let rotationDelta = 0 let rotationDir: 1 | -1 | 0 = 0
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey) if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
rotationDelta = ROTATION_STEP rotationDir = 1
else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey) else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
rotationDelta = -ROTATION_STEP rotationDir = -1
if (rotationDelta !== 0) { if (rotationDir !== 0) {
event.preventDefault() event.preventDefault()
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
const currentRotation = draft.rotation const currentRotation = draft.rotation
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta // Round to the nearest 45° then step, matching the placed-item R/T.
const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir)
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]] draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
// Ref + cursor mesh + item mesh — no store update during drag // Ref + cursor mesh + item mesh — no store update during drag
@@ -1908,8 +1973,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onKeyUp = (event: KeyboardEvent) => { const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') { if (event.key === 'Alt') {
shiftFreeRef.current = false altFreeRef.current = false
revalidate() revalidate()
} }
} }
@@ -1926,13 +1991,35 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
// ---- Right-click cancel ---- // ---- Right-click cancel (quick click only, never a right-drag orbit) ----
// The right button is also the camera-orbit control, so a contextmenu/up
// alone can't tell "cancel placement" from "move the camera". Record the
// right-button-down point + time and only cancel on release when the
// pointer barely moved within a short window — a longer / further press is
// an orbit and must leave the placement untouched.
let rightDown: { x: number; y: number; t: number } | null = null
const onRightPointerDown = (event: PointerEvent) => {
if (event.button !== 2) return
rightDown = { x: event.clientX, y: event.clientY, t: performance.now() }
}
const onRightPointerUp = (event: PointerEvent) => {
if (event.button !== 2) return
const down = rightDown
rightDown = null
if (!down || !configRef.current.onCancel) return
const movedSq = (event.clientX - down.x) ** 2 + (event.clientY - down.y) ** 2
const elapsed = performance.now() - down.t
if (movedSq <= RIGHT_CLICK_CANCEL_MAX_MOVE_PX ** 2 && elapsed <= RIGHT_CLICK_CANCEL_MAX_MS) {
onCancel()
}
}
// Suppress the OS context menu while placing; the cancel itself is decided
// on pointerup above.
const onContextMenu = (event: MouseEvent) => { const onContextMenu = (event: MouseEvent) => {
if (configRef.current.onCancel) { if (configRef.current.onCancel) event.preventDefault()
event.preventDefault()
configRef.current.onCancel()
}
} }
window.addEventListener('pointerdown', onRightPointerDown, true)
window.addEventListener('pointerup', onRightPointerUp, true)
window.addEventListener('contextmenu', onContextMenu) window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ---- // ---- Bounding box geometry ----
@@ -1997,6 +2084,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('shelf:move', onShelfMove) emitter.on('shelf:move', onShelfMove)
emitter.on('shelf:click', onShelfClick) emitter.on('shelf:click', onShelfClick)
emitter.on('shelf:leave', onShelfLeave) emitter.on('shelf:leave', onShelfLeave)
// A floor placement commits at the tracked floor cursor (`gridPosition`),
// which keeps following the floor even when the click ray lands on a wall
// (grid:move uses a separate ground-plane raycast). Without this, a commit
// click whose ray hits a wall fires only `wall:click` — whose handler
// declines for a floor item — and the click is silently eaten (the user
// has to click again until the ray happens to clear the wall). Route every
// surface click to the floor commit too; `floorStrategy.click` guards on
// `surface === 'floor'` (and a non-attach draft), so it no-ops while the
// draft is actually resting on that surface.
const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
if (placementState.current.surface !== 'floor') return
onGridClick(event as unknown as GridEvent)
}
emitter.on('wall:click', commitFloorOnSurfaceClick as never)
emitter.on('item:click', commitFloorOnSurfaceClick as never)
emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
emitter.on('roof:click', commitFloorOnSurfaceClick as never)
emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
if (dragMode) window.addEventListener('pointerup', onReleaseCommit) if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
return () => { return () => {
@@ -2032,9 +2138,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('shelf:move', onShelfMove) emitter.off('shelf:move', onShelfMove)
emitter.off('shelf:click', onShelfClick) emitter.off('shelf:click', onShelfClick)
emitter.off('shelf:leave', onShelfLeave) emitter.off('shelf:leave', onShelfLeave)
emitter.off('wall:click', commitFloorOnSurfaceClick as never)
emitter.off('item:click', commitFloorOnSurfaceClick as never)
emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
emitter.off('roof:click', commitFloorOnSurfaceClick as never)
emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('pointerdown', onRightPointerDown, true)
window.removeEventListener('pointerup', onRightPointerUp, true)
window.removeEventListener('contextmenu', onContextMenu) window.removeEventListener('contextmenu', onContextMenu)
} }
}, [ }, [
@@ -2114,7 +2227,74 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Restore the draft mesh's raycast when the coordinator unmounts (tool change). // Restore the draft mesh's raycast when the coordinator unmounts (tool change).
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast]) useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
useFrame((_, delta) => { // Publish the ghost's surface (contact point + normal) so the grid's snap
// patch sits at the item's resolved height (e.g. a shelf top) and orients to
// the surface (vertical in a wall plane), AND publish the forward-facing
// triangle pose to the single editor-side overlay (`<FacingPoseIndicator>`)
// instead of drawing an inline triangle. Only this coordinator publishes — a
// moving existing node has no draft here, so the grid reads that case straight
// off the node's mesh. Cleared when idle.
const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
const facingForwardRef = useRef(new Vector3(0, 0, 1))
const facingQuatRef = useRef(new Quaternion())
useFrame(() => {
const ghost = cursorGroupRef.current
if (!(asset && ghost)) {
clearPlacementSurface()
useFacingPose.getState().clear()
return
}
const surf = placementState.current.surface
const n = surfaceNormalRef.current
const shape = facingShapeRef.current
// Triangle yaw / Y default to the floor case: the cursor group's own yaw is
// the item's forward on the floor, and the triangle rides at the ghost's Y.
let facingYaw = ghost.rotation.y
let facingY = ghost.position.y
if (surf === 'wall' || surf === 'roof-wall') {
// Wall/roof-segment faces: the cursor group's yaw is the symmetric
// wireframe yaw (π off the real facing for a wall, and a different frame
// for a roof face), so derive the item's TRUE outward facing from the
// draft mesh's world orientation — its local +Z faces out of the host
// surface. This keeps BOTH the grid normal and the triangle correct for
// wall and roof-segment hosts alike, rather than the old quaternion read
// that pointed the wrong way.
const mesh = draftNode.current ? sceneRegistry.nodes.get(draftNode.current.id) : null
if (mesh) {
mesh.getWorldQuaternion(facingQuatRef.current)
const fwd = facingForwardRef.current.set(0, 0, 1).applyQuaternion(facingQuatRef.current)
fwd.y = 0
if (fwd.lengthSq() > 1e-6) facingYaw = Math.atan2(fwd.x, fwd.z)
}
// The forward triangle is a floor aid; drop it to the building-local floor
// under the wall (the ghost Y is up on the wall).
facingY = 0
n.set(Math.sin(facingYaw), 0, Math.cos(facingYaw))
} else {
n.set(0, 1, 0)
}
publishPlacementSurface(ghost.position, n)
if (shape.depth > 0) {
useFacingPose.getState().set({
position: [ghost.position.x, facingY, ghost.position.z],
rotationY: facingYaw,
depth: shape.depth,
center: shape.center,
})
} else {
useFacingPose.getState().clear()
}
})
useEffect(
() => () => {
clearPlacementSurface()
useFacingPose.getState().clear()
},
[],
)
useFrame(() => {
if (!asset) { if (!asset) {
reconcileDraftRaycast(null) reconcileDraftRaycast(null)
return return
@@ -2145,12 +2325,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
mesh.visible = true mesh.visible = true
if (placementState.current.surface === 'floor') { if (placementState.current.surface === 'floor') {
const distance = mesh.position.distanceToSquared(gridPosition.current) // Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
if (distance > 1) { // active move visibly trail the cursor and — combined with React
// 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) mesh.position.copy(gridPosition.current)
} else {
mesh.position.lerp(gridPosition.current, delta * 20)
}
// Adjust Y for slab elevation (floor items on top of slabs) // Adjust Y for slab elevation (floor items on top of slabs)
if (!asset.attachTo) { if (!asset.attachTo) {
@@ -2199,6 +2380,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), []) const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), []) const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
// Feed the footprint shape to the per-frame surface publisher, which orients
// and positions the forward-facing triangle via `useFacingPose`.
facingShapeRef.current = {
depth: currentDimensionBounds.dimensions[2],
center: [currentDimensionBounds.center[0], currentDimensionBounds.center[2]],
}
const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit) const widthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[0], unit)
const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit) const depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit) const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
@@ -2217,7 +2404,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
currentDimensionBounds.dimensions[1] / 2, currentDimensionBounds.dimensions[1] / 2,
currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2, currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
] ]
const measurementContent = ( const measurementContent = (
<> <>
<lineSegments <lineSegments
@@ -16,6 +16,7 @@ import {
type PortConnectivity, type PortConnectivity,
resolveAlignment, resolveAlignment,
resolveConnectivityUpdates, resolveConnectivityUpdates,
resolveFacingIndicator,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveNodeOverrides, useLiveNodeOverrides,
@@ -30,7 +31,9 @@ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { swallowNextClick } from '../../editor/node-arrow-handles' import { swallowNextClick } from '../../editor/node-arrow-handles'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { DragBoundingBox } from '../shared/drag-bounding-box' import { DragBoundingBox } from '../shared/drag-bounding-box'
@@ -41,6 +44,7 @@ import { PlacementBox } from '../shared/placement-box'
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */ * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
const snapToGridStep = (value: number) => { const snapToGridStep = (value: number) => {
if (!resolveSnapFlags(getActiveSnappingMode()).grid) return value
const step = useEditor.getState().gridSnapStep const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step return Math.round(value / step) * step
} }
@@ -218,18 +222,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// commit / cancel / unmount so a follow-on drag starts clean. // commit / cancel / unmount so a follow-on drag starts clean.
const overriddenIdsRef = useRef<AnyNodeId[]>([]) const overriddenIdsRef = useRef<AnyNodeId[]>([])
// Shelf placement shows the same green/red footprint box GLB items use // Colliding floor kinds (item / shelf / column) show the same green/red
// (instead of the vertical-arrow cursor) and refuses an invalid drop unless // footprint box GLB items use (instead of the vertical-arrow cursor) and
// Shift forces it. The footprint comes from the kind's `floorPlaced` // refuse an invalid drop unless Alt forces it. The gate + footprint both come
// capability so this stays generic if we ever opt other kinds in. // from the kind's declarative `floorPlaced` capability, so opting a new kind
const isShelf = node.type === 'shelf' // in is just `collides: true` — no change here.
const collides = nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
const boxDimensions = useMemo( const boxDimensions = useMemo(
() => () =>
isShelf collides
? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? ? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
null) null)
: null, : null,
[isShelf, node], [collides, node],
) )
const [valid, setValid] = useState(true) const [valid, setValid] = useState(true)
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
@@ -244,10 +249,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// a register collar drops onto a duct run end. Reads `def.ports` through // a register collar drops onto a duct run end. Reads `def.ports` through
// the core registry, so it stays layer-clean (no @pascal-app/nodes import). // the core registry, so it stays layer-clean (no @pascal-app/nodes import).
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
// Mirrors of `valid` / Shift for the event handlers inside the effect, which // Mirrors of `valid` / Alt for the event handlers inside the effect, which
// can't read React state without stale closures. // can't read React state without stale closures.
const validRef = useRef(true) const validRef = useRef(true)
const shiftRef = useRef(false) const altRef = useRef(false)
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
@@ -259,7 +264,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
dragAnchorRef.current = null dragAnchorRef.current = null
hasMovedRef.current = false hasMovedRef.current = false
rotationRef.current = originalRotationY rotationRef.current = originalRotationY
shiftRef.current = false altRef.current = false
validRef.current = true validRef.current = true
// Re-sync the box transform to the (possibly new) node. `node` changes // Re-sync the box transform to the (possibly new) node. `node` changes
// without this component remounting whenever a positioned preset re-arms a // without this component remounting whenever a positioned preset re-arms a
@@ -335,12 +340,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
// Re-run the floor-collision check at the live cursor + rotation and push // Re-run the floor-collision check at the live cursor + rotation and push
// the result to the box colour. Shift forces a valid (green) override so // the result to the box colour. Alt (free place) forces a valid (green)
// the user can drop on top of an existing item on purpose. Only shelves // override so the user can drop on top of an existing item on purpose. Only
// show the box, so this no-ops for every other movable kind. // shelves show the box, so this no-ops for every other movable kind.
const recomputeValidity = () => { const recomputeValidity = () => {
if (!boxDimensions) return if (!boxDimensions) return
if (shiftRef.current) { if (altRef.current) {
validRef.current = true validRef.current = true
setValid(true) setValid(true)
return return
@@ -417,7 +422,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
original: [originalPosition[0], originalPosition[2]], original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchorRef.current, anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep, // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
snap: snapToGridStep,
}) })
dragAnchorRef.current = resolved.anchor dragAnchorRef.current = resolved.anchor
let [x, z] = resolved.point let [x, z] = resolved.point
@@ -426,8 +432,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// moving item's edge lines up (on X or Z) with another item's edge, // moving item's edge lines up (on X or Z) with another item's edge,
// snap and publish a guide. The guide connects to the nearest real // snap and publish a guide. The guide connects to the nearest real
// corner of the candidate (resolver tie-break), so the dot always sits // corner of the candidate (resolver tie-break), so the dot always sits
// on an actual point. Alt bypasses alignment; Shift bypasses all snap. // on an actual point. Alignment ("lines") follows the snapping mode only —
const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true // Alt is force-place (forces a valid drop), it does not bypass snapping.
const bypass = !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationRef.current), moving: movingFootprintAnchors(node, x, z, rotationRef.current),
@@ -488,7 +495,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
previewConnectivity(position, rotationRef.current) previewConnectivity(position, rotationRef.current)
const prev = previousSnapRef.current const prev = previousSnapRef.current
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) { if (!prev || prev[0] !== x || prev[1] !== z) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
previousSnapRef.current = [x, z] previousSnapRef.current = [x, z]
} }
@@ -524,9 +531,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// deliberate drop. Prevents preset re-arm from double-placing. // deliberate drop. Prevents preset re-arm from double-placing.
if (!hasMovedRef.current) return if (!hasMovedRef.current) return
// Refuse a drop on an invalid (red) footprint, matching the GLB item // Refuse a drop on an invalid (red) footprint, matching the GLB item
// tool — unless Shift is held to force placement. Other kinds carry no // tool — unless Alt (free place) is held to force placement. Other kinds
// validity box (`validRef` stays true), so they're never blocked. // carry no validity box (`validRef` stays true), so they're never blocked.
if (!validRef.current && !shiftRef.current) return if (!validRef.current && !altRef.current) return
const position: [number, number, number] = [...lastCursorRef.current] const position: [number, number, number] = [...lastCursorRef.current]
const rotation = toCommitRotation(rotationRef.current) const rotation = toCommitRotation(rotationRef.current)
@@ -624,10 +631,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// item placement keys (and the "Rotate" hints the move HUD shows). Applied // item placement keys (and the "Rotate" hints the move HUD shows). Applied
// imperatively + mirrored to the live transform; committed on drop. // imperatively + mirrored to the live transform; committed on drop.
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
// Hold Shift to force placement on an invalid (red) footprint, matching // Hold Alt (free place) to force placement on an invalid (red) footprint,
// the GLB item tool. Recolour the box to green while held. // matching the GLB item tool. Recolour the box to green while held.
if (e.key === 'Shift') { if (e.key === 'Alt') {
shiftRef.current = true altRef.current = true
recomputeValidity() recomputeValidity()
return return
} }
@@ -659,8 +666,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
recomputeValidity() recomputeValidity()
} }
const onKeyUp = (e: KeyboardEvent) => { const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') { if (e.key === 'Alt') {
shiftRef.current = false altRef.current = false
recomputeValidity() recomputeValidity()
} }
} }
@@ -768,6 +775,23 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
[node], [node],
) )
// Forward-facing triangle for the footprint-box branch (item / shelf / column
// — anything that renders `<PlacementBox>`). Published to the editor-side
// overlay; the `<DragBoundingBox>` branch (e.g. stair, which has no centred
// footprint) publishes its own. The box is centred on `cursorPosition`, so
// the footprint centre is the origin. Clears on unmount.
const facing = resolveFacingIndicator(node.type)
useEffect(() => {
if (!previewVisible || !facing || !boxDimensions) return
useFacingPose.getState().set({
position: cursorPosition,
rotationY: cursorRotationY,
depth: boxDimensions[2],
reversed: facing.reversed,
})
}, [previewVisible, facing, boxDimensions, cursorPosition, cursorRotationY])
useEffect(() => () => useFacingPose.getState().clear(), [])
if (!previewVisible) return null if (!previewVisible) return null
if (boxDimensions) { if (boxDimensions) {
@@ -23,7 +23,7 @@ import {
resolveAlignmentForActiveBuilding, resolveAlignmentForActiveBuilding,
snapWorldXZForActiveBuilding, snapWorldXZForActiveBuilding,
} from '../../../lib/world-grid-snap' } from '../../../lib/world-grid-snap'
import useEditor from '../../../store/use-editor' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_WALL_HEIGHT = 0.5
@@ -187,7 +187,8 @@ export const RoofTool: React.FC = () => {
// point: resolving against the grid point would only ever catch anchors // point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled // that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the // walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid snap. Alt bypasses. // candidate's coordinate; the other axis keeps its grid snap. Alignment runs
// only when the magnetic (lines) snapping mode is active.
const alignPoint = ( const alignPoint = (
gridX: number, gridX: number,
gridZ: number, gridZ: number,
@@ -241,21 +242,22 @@ export const RoofTool: React.FC = () => {
if (!cursorRef.current) return if (!cursorRef.current) return
// World-grid snap projected into building-local; rotated buildings // World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid. // used to drag every roof corner off the visible grid. Snapping follows
const bypassSnap = event.nativeEvent?.shiftKey === true // the global mode (grid quantize / lines alignment); Off keeps the raw
const snapped: [number, number] = bypassSnap // cursor. Shift cycles the mode centrally — this tool never reads it.
? [event.localPosition[0], event.localPosition[2]] const snapped: [number, number] = isGridSnapActive()
: snapWorldXZForActiveBuilding( ? snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
).local ).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
snapped[0], snapped[0],
snapped[1], snapped[1],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
const y = event.localPosition[1] const y = event.localPosition[1]
@@ -265,7 +267,7 @@ export const RoofTool: React.FC = () => {
cursorRef.current.position.set(gridX, gridY, gridZ) cursorRef.current.position.set(gridX, gridY, gridZ)
if ( if (
!bypassSnap && (isGridSnapActive() || isMagneticSnapActive()) &&
corner1Ref.current && corner1Ref.current &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
@@ -290,21 +292,21 @@ export const RoofTool: React.FC = () => {
if (!currentLevelId) return if (!currentLevelId) return
// World-grid snap projected into building-local; rotated buildings // World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid. // used to drag every roof corner off the visible grid. Snapping follows
const bypassSnap = event.nativeEvent?.shiftKey === true // the global mode; Off keeps the raw cursor.
const snapped: [number, number] = bypassSnap const snapped: [number, number] = isGridSnapActive()
? [event.localPosition[0], event.localPosition[2]] ? snapWorldXZForActiveBuilding(
: snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
).local ).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
snapped[0], snapped[0],
snapped[1], snapped[1],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
const y = event.localPosition[1] const y = event.localPosition[1]
@@ -4,6 +4,7 @@ import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { Box3, type Camera, type Object3D, Vector3 } from 'three' import { Box3, type Camera, type Object3D, Vector3 } from 'three'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import { import {
clearBoxSelectHandled, clearBoxSelectHandled,
isBoxSelectPointerSuppressed, isBoxSelectPointerSuppressed,
@@ -191,6 +192,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
const currentClientXRef = useRef(0) const currentClientXRef = useRef(0)
const currentClientYRef = useRef(0) const currentClientYRef = useRef(0)
const spaceDownRef = useRef(false) const spaceDownRef = useRef(false)
// rAF throttle for the expensive marquee preview pass. pointermove can fire
// several times per animation frame; the per-node AABB projection in
// `collectNodeIdsInScreenRect` only needs to run once per frame. We stash the
// latest clamped rect and process it inside the rAF callback.
const previewRafRef = useRef<number | null>(null)
const pendingPreviewRectRef = useRef<ScreenRect | null>(null)
const syncPreviewSelectedIds = useCallback( const syncPreviewSelectedIds = useCallback(
(nextIds: string[]) => { (nextIds: string[]) => {
@@ -206,6 +213,11 @@ const ScreenRectangleSelectTool: React.FC = () => {
pointerDownRef.current = false pointerDownRef.current = false
isDraggingRef.current = false isDraggingRef.current = false
pointerIdRef.current = null pointerIdRef.current = null
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current)
previewRafRef.current = null
}
pendingPreviewRectRef.current = null
hideScreenRectangleSelectionElement(elementRef.current) hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
@@ -213,6 +225,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
useViewer.getState().setInputDragging(false) useViewer.getState().setInputDragging(false)
ownsInputDraggingRef.current = false ownsInputDraggingRef.current = false
} }
useInteractionScope.getState().endIf((s) => s.kind === 'box-select')
}, [syncPreviewSelectedIds]) }, [syncPreviewSelectedIds])
useEffect(() => { useEffect(() => {
@@ -263,6 +276,14 @@ const ScreenRectangleSelectTool: React.FC = () => {
useEffect(() => { useEffect(() => {
const canvas = gl.domElement const canvas = gl.domElement
const flushPreview = () => {
previewRafRef.current = null
const rect = pendingPreviewRectRef.current
if (!rect) return
pendingPreviewRectRef.current = null
syncPreviewSelectedIds(collectNodeIdsInScreenRect(rect, camera, canvas))
}
const updateDrag = (event: PointerEvent) => { const updateDrag = (event: PointerEvent) => {
if (!pointerDownRef.current) return if (!pointerDownRef.current) return
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
@@ -291,6 +312,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
isDraggingRef.current = true isDraggingRef.current = true
ownsInputDraggingRef.current = true ownsInputDraggingRef.current = true
useViewer.getState().setInputDragging(true) useViewer.getState().setInputDragging(true)
useInteractionScope.getState().begin({ kind: 'box-select' })
markBoxSelectHandled() markBoxSelectHandled()
try { try {
canvas.setPointerCapture(event.pointerId) canvas.setPointerCapture(event.pointerId)
@@ -311,13 +333,22 @@ const ScreenRectangleSelectTool: React.FC = () => {
screenRectFromDomRect(canvas.getBoundingClientRect()), screenRectFromDomRect(canvas.getBoundingClientRect()),
) )
if (!clampedRect) { if (!clampedRect) {
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current)
previewRafRef.current = null
}
pendingPreviewRectRef.current = null
hideScreenRectangleSelectionElement(elementRef.current) hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
return return
} }
updateScreenRectangleSelectionElement(elementRef.current!, clampedRect) updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas)) // Coalesce the per-node AABB projection to one run per animation frame.
pendingPreviewRectRef.current = clampedRect
if (previewRafRef.current === null) {
previewRafRef.current = requestAnimationFrame(flushPreview)
}
} }
const finishDrag = (event: PointerEvent) => { const finishDrag = (event: PointerEvent) => {
@@ -1,6 +1,6 @@
'use client' 'use client'
import { sceneRegistry } from '@pascal-app/core' import { type AnyNodeId, resolveFacingIndicator, sceneRegistry, useScene } from '@pascal-app/core'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { import {
Box3, Box3,
@@ -15,6 +15,7 @@ import {
import { distance, smoothstep, uv, vec2 } from 'three/tsl' import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import useFacingPose from '../../../store/use-facing-pose'
const NO_RAYCAST = () => null const NO_RAYCAST = () => null
@@ -93,6 +94,9 @@ export function DragBoundingBox({
centerY, centerY,
color = DEFAULT_COLOR, color = DEFAULT_COLOR,
}: DragBoundingBoxProps) { }: DragBoundingBoxProps) {
const nodeType = useScene((state) => state.nodes[nodeId as AnyNodeId]?.type)
const facing = nodeType ? resolveFacingIndicator(nodeType) : null
const measured = useMemo(() => { const measured = useMemo(() => {
if (size) return null if (size) return null
const obj = sceneRegistry.nodes.get(nodeId) const obj = sceneRegistry.nodes.get(nodeId)
@@ -104,6 +108,7 @@ export function DragBoundingBox({
? [0, centerY ?? size[1] / 2, 0] ? [0, centerY ?? size[1] / 2, 0]
: (measured?.center ?? [0, fallbackSize[1] / 2, 0]) : (measured?.center ?? [0, fallbackSize[1] / 2, 0])
const minY = cy - h / 2 const minY = cy - h / 2
const groundY = minY + 0.01
const edgeGeometry = useMemo(() => { const edgeGeometry = useMemo(() => {
const box = new BoxGeometry(w, h, d) const box = new BoxGeometry(w, h, d)
@@ -117,9 +122,9 @@ export function DragBoundingBox({
const planeGeometry = useMemo(() => { const planeGeometry = useMemo(() => {
const plane = new PlaneGeometry(w, d) const plane = new PlaneGeometry(w, d)
plane.rotateX(-Math.PI / 2) plane.rotateX(-Math.PI / 2)
plane.translate(cx, minY + 0.01, cz) plane.translate(cx, groundY, cz)
return plane return plane
}, [w, d, cx, minY, cz]) }, [w, d, cx, groundY, cz])
const edgeMaterial = useMemo( const edgeMaterial = useMemo(
() => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }), () => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }),
@@ -147,6 +152,22 @@ export function DragBoundingBox({
[edgeGeometry, planeGeometry, edgeMaterial, planeMaterial], [edgeGeometry, planeGeometry, edgeMaterial, planeMaterial],
) )
// Publish the facing pose to the editor-side overlay (the single triangle
// renderer) rather than drawing it here. The node origin is `position`; the
// footprint centre is `[cx, cz]` in the node's local frame. Runs each drag
// frame so the triangle follows; a separate mount/unmount effect clears it.
useEffect(() => {
if (!facing || d <= 0) return
useFacingPose.getState().set({
position: [position[0], position[1] + groundY, position[2]],
rotationY,
depth: d,
center: [cx, cz],
reversed: facing.reversed,
})
}, [facing, position, rotationY, d, cx, cz, groundY])
useEffect(() => () => useFacingPose.getState().clear(), [])
if (w <= 0 || h <= 0 || d <= 0) return null if (w <= 0 || h <= 0 || d <= 0) return null
return ( return (
@@ -0,0 +1,87 @@
import { useEffect, useMemo } from 'react'
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
// A flat forward-pointing triangle drawn on the floor just in front of a
// placement ghost, so the direction the node will face is obvious. Tip at local
// +Z (every kind's forward face); render it inside the ghost's rotated group so
// it inherits the node's yaw. Matches the item coordinator / drag-bounding-box
// indicators (same size, colour, double-sided so it shows from above).
const FACING_INDICATOR_WIDTH = 0.4
const FACING_INDICATOR_LENGTH = 0.46
const FACING_INDICATOR_GAP = 0.45
/**
* @param depth bbox depth (along local Z) of the ghost — positions the
* triangle just past the front edge.
* @param center optional [x, z] of the bbox centre in the ghost's local frame.
* @param reversed point along local -Z (the front is the -Z side, e.g. a stair
* entry) instead of +Z.
* @param y small lift off the floor to avoid z-fighting.
*/
export function FacingIndicator({
depth,
center = [0, 0],
reversed = false,
y = 0.02,
}: {
depth: number
center?: [number, number]
reversed?: boolean
y?: number
}) {
const dir = reversed ? -1 : 1
// Per-instance geometry/material (not module singletons) so this works no
// matter which package mounts it (the tools live in `nodes`, imported via
// `@pascal-app/editor`). Disposed on unmount.
const geometry = useMemo(() => {
const g = new BufferGeometry()
g.setAttribute(
'position',
new Float32BufferAttribute(
[
0,
0,
dir * FACING_INDICATOR_LENGTH,
FACING_INDICATOR_WIDTH / 2,
0,
0,
-FACING_INDICATOR_WIDTH / 2,
0,
0,
],
3,
),
)
return g
}, [dir])
const material = useMemo(
() =>
new MeshBasicNodeMaterial({
color: 0x22_c5_5e, // green-500 (forward)
depthTest: false,
depthWrite: false,
side: DoubleSide,
}),
[],
)
useEffect(
() => () => {
geometry.dispose()
material.dispose()
},
[geometry, material],
)
return (
<mesh
frustumCulled={false}
geometry={geometry}
layers={EDITOR_LAYER}
material={material}
position={[center[0], y, center[1] + dir * (depth / 2 + FACING_INDICATOR_GAP)]}
renderOrder={1001}
/>
)
}
@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import useFacingPose, { type FacingPose } from '../../../store/use-facing-pose'
import { FacingIndicator } from './facing-indicator'
// The single editor-side renderer for the placement/move facing triangle.
// Mounted once inside ToolManager's building-local group; every tool publishes
// its ghost pose to `useFacingPose` and this draws the triangle. The pose
// (position/yaw) is applied imperatively to a ref so the per-frame cursor
// updates don't re-render React — only a change in footprint shape (depth /
// centre), which is constant per tool session, triggers a re-render.
export function FacingPoseIndicator() {
const groupRef = useRef<Group>(null)
const [shape, setShape] = useState<Pick<FacingPose, 'depth' | 'center' | 'reversed'> | null>(null)
useEffect(() => {
const apply = (pose: FacingPose | null) => {
const group = groupRef.current
if (group) {
if (pose) {
group.visible = true
group.position.set(...pose.position)
group.rotation.y = pose.rotationY
} else {
group.visible = false
}
}
setShape((prev) => {
if (!pose) return null
const center = pose.center ?? [0, 0]
if (
prev &&
prev.depth === pose.depth &&
prev.reversed === pose.reversed &&
(prev.center ?? [0, 0])[0] === center[0] &&
(prev.center ?? [0, 0])[1] === center[1]
) {
return prev
}
return { depth: pose.depth, center, reversed: pose.reversed }
})
}
apply(useFacingPose.getState().pose)
return useFacingPose.subscribe((state) => apply(state.pose))
}, [])
return (
<group ref={groupRef} visible={false}>
{shape ? (
<FacingIndicator center={shape.center} depth={shape.depth} reversed={shape.reversed} />
) : null}
</group>
)
}
@@ -746,10 +746,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const point = levelNode ? event.localPosition : event.position const point = levelNode ? event.localPosition : event.position
const rawPoint: [number, number] = [point[0], point[2]] const rawPoint: [number, number] = [point[0], point[2]]
const bypassSnap = event.nativeEvent.shiftKey === true // Snapping follows the active mode (snapToHalf returns raw in Off / non-grid);
const gridPoint: [number, number] = bypassSnap // no Shift bypass — Shift cycles the mode, Off is the bypass.
? rawPoint const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
: [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
const newPosition = const newPosition =
dragState?.isDragging && resolvePlanPoint dragState?.isDragging && resolvePlanPoint
? resolvePlanPoint({ ? resolvePlanPoint({
@@ -766,7 +765,6 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Play snap sound when cursor moves to a new grid cell during drag // Play snap sound when cursor moves to a new grid cell during drag
if ( if (
!bypassSnap &&
dragState?.isDragging && dragState?.isDragging &&
previousPositionRef.current && previousPositionRef.current &&
(newPosition[0] !== previousPositionRef.current[0] || (newPosition[0] !== previousPositionRef.current[0] ||
@@ -17,6 +17,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary' import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary'
import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor' import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import { import {
ARROW_COLOR, ARROW_COLOR,
ARROW_HOVER_COLOR, ARROW_HOVER_COLOR,
@@ -376,14 +377,20 @@ export const SiteBoundaryEditor: React.FC = () => {
if (!siteId) return if (!siteId) return
const editor = useEditor.getState()
if (isDragging) { if (isDragging) {
editor.setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL }) useInteractionScope
} else if ( .getState()
editor.activeHandleDrag?.nodeId === siteId && .begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL } else {
const scope = useInteractionScope.getState().scope
const activeHandleDrag =
scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
if (
activeHandleDrag?.nodeId === siteId &&
activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
) { ) {
editor.setActiveHandleDrag(null) useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
}
} }
if (!isDragging) { if (!isDragging) {
@@ -396,12 +403,14 @@ export const SiteBoundaryEditor: React.FC = () => {
useEffect( useEffect(
() => () => { () => () => {
if (!siteId) return if (!siteId) return
const editor = useEditor.getState() const scope = useInteractionScope.getState().scope
const activeHandleDrag =
scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
if ( if (
editor.activeHandleDrag?.nodeId === siteId && activeHandleDrag?.nodeId === siteId &&
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
) { ) {
editor.setActiveHandleDrag(null) useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
} }
useLiveNodeOverrides.getState().clearFields(siteId, ['polygon']) useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
isDraggingSiteBoundaryRef.current = false isDraggingSiteBoundaryRef.current = false
@@ -23,6 +23,8 @@ import {
resolveStairDestinationLevel, resolveStairDestinationLevel,
resolveStairPlacementLevelId, resolveStairPlacementLevelId,
} from '../../../lib/stair-levels' } from '../../../lib/stair-levels'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
@@ -263,7 +265,19 @@ export const StairTool: React.FC = () => {
return { placementLevelId, previewNodes, stair } return { placementLevelId, previewNodes, stair }
} }
// The preview rebuild (full-scene copy + destination-level resolution +
// auto-opening CSG) is expensive; `grid:move` fires it every pointer event
// but the placed position is grid-snapped, so within a cell every rebuild
// is identical. Dedupe on the snapped position + rotation so we rebuild
// only when the staircase would actually land somewhere new — this is the
// difference between a smooth and a stuttering stair tool (the elevator is
// cheap because it has no opening sync).
let lastPreviewKey: string | null = null
const applyDraftPreview = (position: [number, number, number], rotation: number) => { const applyDraftPreview = (position: [number, number, number], rotation: number) => {
const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}`
if (key === lastPreviewKey) return
lastPreviewKey = key
const preview = buildPreviewScene(position, rotation) const preview = buildPreviewScene(position, rotation)
const visualPosition = preview const visualPosition = preview
? getFloorStackPreviewPosition({ ? getFloorStackPreviewPosition({
@@ -287,6 +301,19 @@ export const StairTool: React.FC = () => {
previewRef.current.rotation.y = rotation previewRef.current.rotation.y = rotation
} }
// Forward-facing triangle (editor-side overlay). The run ascends along
// local +Z from the entry at z≈0; the stair's front is the -Z entry side,
// so `reversed` points the triangle out of the entry (where you approach
// from), sitting just before it — not inside the footprint or at the
// elevated far end. Centre is the footprint mid-run (origin is the entry).
useFacingPose.getState().set({
position: visualPosition,
rotationY: rotation,
depth: DEFAULT_STAIR_LENGTH,
center: [0, DEFAULT_STAIR_LENGTH / 2],
reversed: true,
})
if (!preview) { if (!preview) {
openingPreview.clear() openingPreview.clear()
return return
@@ -319,7 +346,8 @@ export const StairTool: React.FC = () => {
// The probe is the RAW cursor, not the grid-snapped point: resolving // The probe is the RAW cursor, not the grid-snapped point: resolving
// against the grid point would only catch anchors that happen to sit near // against the grid point would only catch anchors that happen to sit near
// a grid line. Matched axes use the raw probe + snap delta; unmatched axes // a grid line. Matched axes use the raw probe + snap delta; unmatched axes
// keep the normal grid snap. Alt bypasses. // keep the normal grid snap. Alignment runs only when the magnetic (lines)
// snapping mode is active.
const alignPoint = ( const alignPoint = (
gridX: number, gridX: number,
gridZ: number, gridZ: number,
@@ -348,20 +376,26 @@ export const StairTool: React.FC = () => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true // Grid snap follows the global mode (live step so the HUD chip is
// honest); Off keeps the raw cursor. Shift cycles the mode centrally.
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, isGridSnapActive()
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, ? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
const position: [number, number, number] = [gridX, 0, gridZ] const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position lastCanonicalPositionRef.current = position
applyDraftPreview(position, rotationRef.current) applyDraftPreview(position, rotationRef.current)
if ( if (
!bypassSnap && (isGridSnapActive() || isMagneticSnapActive()) &&
previousGridPosRef.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) { ) {
@@ -372,13 +406,17 @@ export const StairTool: React.FC = () => {
} }
const getAlignedGridPosition = (event: GridEvent): [number, number, number] => { const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
const bypassSnap = event.nativeEvent?.shiftKey === true const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint( const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2, isGridSnapActive()
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2, ? Math.round(event.localPosition[0] / step) * step
: event.localPosition[0],
isGridSnapActive()
? Math.round(event.localPosition[2] / step) * step
: event.localPosition[2],
event.localPosition[0], event.localPosition[0],
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap, !isMagneticSnapActive(),
) )
return [gridX, 0, gridZ] return [gridX, 0, gridZ]
} }
@@ -398,8 +436,20 @@ export const StairTool: React.FC = () => {
commitStairPlacement(currentLevelId, position, rotationRef.current) commitStairPlacement(currentLevelId, position, rotationRef.current)
openingPreview.clear() openingPreview.clear()
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) // Commit cleared the opening preview, so force the next hover (even on the
// same cell) to rebuild rather than dedupe against the just-placed key.
lastPreviewKey = null
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
// Single by default; the C-toggle ('point' context, shared with every
// other placement tool) opts into placing more. On single, drop the tool
// and the facing triangle so we fall back to select after one stair.
if (useEditor.getState().getContinuation('point') === 'repeat') {
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
} else {
useFacingPose.getState().clear()
useEditor.getState().setTool(null)
}
} }
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -444,6 +494,7 @@ export const StairTool: React.FC = () => {
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
openingPreview.clear() openingPreview.clear()
useFacingPose.getState().clear()
} }
}, [currentLevelId]) }, [currentLevelId])
@@ -451,7 +502,9 @@ export const StairTool: React.FC = () => {
<group> <group>
<CursorSphere ref={cursorRef} /> <CursorSphere ref={cursorRef} />
{/* 3D ghost preview — position/rotation updated imperatively */} {/* 3D ghost preview — position/rotation updated imperatively. The
forward-facing triangle is drawn by the editor-side overlay from the
pose published in `applyDraftPreview`. */}
<group ref={previewRef}> <group ref={previewRef}>
<mesh castShadow geometry={previewGeometry}> <mesh castShadow geometry={previewGeometry}>
<meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent /> <meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
@@ -2,13 +2,22 @@ import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
type CeilingNode, type CeilingNode,
type FenceNode,
nodeRegistry, nodeRegistry,
type SlabNode, type SlabNode,
useScene, useScene,
type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react' import { type ComponentType, lazy, Suspense, useMemo } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor' import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import {
useEditingHole,
useEndpointReshape,
useIsCurveReshape,
useMovingNode,
useReshapingNode,
} from '../../store/use-interaction-scope'
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer' import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
@@ -16,6 +25,7 @@ import { ElevatorTool } from './elevator/elevator-tool'
import { MoveTool } from './item/move-tool' import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool' import { RoofTool } from './roof/roof-tool'
import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
import { FacingPoseIndicator } from './shared/facing-pose-indicator'
import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { StairTool } from './stair/stair-tool' import { StairTool } from './stair/stair-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -55,12 +65,23 @@ export const ToolManager: React.FC = () => {
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useMovingNode()
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const endpointReshape = useEndpointReshape()
const curvingWall = useEditor((state) => state.curvingWall) const isCurveReshape = useIsCurveReshape()
const curvingFence = useEditor((state) => state.curvingFence) const reshapingNode = useReshapingNode()
const editingHole = useEditor((state) => state.editingHole) // The endpoint affordance tool's `target` is kind-specific
// (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node +
// the scope's endpoint. Memoised so it stays referentially stable across the
// scene-write re-renders during the drag — otherwise a fresh object each frame
// re-fires the tool's setup effect (endpoint drag would loop / freeze).
const endpointTarget = useMemo(() => {
if (!(endpointReshape && reshapingNode)) return null
return reshapingNode.type === 'fence'
? { fence: reshapingNode as FenceNode, endpoint: endpointReshape.endpoint }
: { wall: reshapingNode as WallNode, endpoint: endpointReshape.endpoint }
}, [endpointReshape, reshapingNode])
const editingHole = useEditingHole()
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const buildingId = useViewer((state) => state.selection.buildingId) const buildingId = useViewer((state) => state.selection.buildingId)
@@ -134,6 +155,16 @@ export const ToolManager: React.FC = () => {
// Show build tools when in build mode // Show build tools when in build mode
const showBuildTool = mode === 'build' && tool !== null const showBuildTool = mode === 'build' && tool !== null
// A move initiated from the 2D floor-plan (orange move-dot) is owned end-to-
// end by `FloorplanRegistryMoveOverlay`, which marks the origin `'2d'` at
// dot-down. Mounting the 3D affordance mover alongside it would adopt the
// same node and, on its unmount, restore the adopt-time position — snapping
// the committed 2D move back to its start. Gate the 3D mover off for 2D moves
// (the scene writes the overlay makes still mirror into the 3D view). A
// 3D-initiated move leaves the origin null until its own commit, so this only
// suppresses the 3D tool for genuinely 2D-owned moves.
const showMover = movingNode != null && movingNodeOrigin !== '2d'
// Registry-first: if the active tool's kind has a NodeDefinition with a // Registry-first: if the active tool's kind has a NodeDefinition with a
// tool contribution, the registry-driven tool takes over. // tool contribution, the registry-driven tool takes over.
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
@@ -163,7 +194,7 @@ export const ToolManager: React.FC = () => {
<> <>
{/* World-space tools: site boundary and building movement operate in world coordinates */} {/* World-space tools: site boundary and building movement operate in world coordinates */}
{showSiteBoundaryEditor && <SiteBoundaryEditor />} {showSiteBoundaryEditor && <SiteBoundaryEditor />}
{movingNode?.type === 'building' && ( {showMover && movingNode?.type === 'building' && (
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} /> <MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
)} )}
@@ -217,49 +248,30 @@ export const ToolManager: React.FC = () => {
</Suspense> </Suspense>
) : null ) : null
})()} })()}
{movingWallEndpoint && {endpointTarget &&
reshapingNode &&
(() => { (() => {
const RegistryAffordance = getRegistryAffordanceTool( const RegistryAffordance = getRegistryAffordanceTool(
movingWallEndpoint.wall.type, reshapingNode.type,
'move-endpoint', 'move-endpoint',
) )
return RegistryAffordance ? ( return RegistryAffordance ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<RegistryAffordance target={movingWallEndpoint} /> <RegistryAffordance target={endpointTarget} />
</Suspense> </Suspense>
) : null ) : null
})()} })()}
{movingFenceEndpoint && {isCurveReshape &&
reshapingNode &&
(() => { (() => {
const RegistryAffordance = getRegistryAffordanceTool( const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
movingFenceEndpoint.fence.type,
'move-endpoint',
)
return RegistryAffordance ? ( return RegistryAffordance ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<RegistryAffordance target={movingFenceEndpoint} /> <RegistryAffordance node={reshapingNode} />
</Suspense> </Suspense>
) : null ) : null
})()} })()}
{curvingWall && {showMover && movingNode.type !== 'building' && (
(() => {
const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve')
return Registry ? (
<Suspense fallback={null}>
<Registry node={curvingWall} />
</Suspense>
) : null
})()}
{curvingFence &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance node={curvingFence} />
</Suspense>
) : null
})()}
{movingNode && movingNode.type !== 'building' && (
<MoveTool <MoveTool
onNodeMoved={handlePlacedNodeSelected} onNodeMoved={handlePlacedNodeSelected}
onSpawnMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected}
@@ -284,6 +296,10 @@ export const ToolManager: React.FC = () => {
tools above. Lives inside the building-local group so the tools above. Lives inside the building-local group so the
building-local guide coords render at the right world position. */} building-local guide coords render at the right world position. */}
<Alignment3DGuideLayer /> <Alignment3DGuideLayer />
{/* The one forward-facing triangle renderer. Placement/move tools
publish their ghost pose to `useFacingPose`; this draws it. Mounted
here so it shares the building-local frame the tools publish in. */}
<FacingPoseIndicator />
{/* Wall-plane proximity / sill / equal-spacing guides for openings, {/* Wall-plane proximity / sill / equal-spacing guides for openings,
published by the door/window move tools in the same world frame. */} published by the door/window move tools in the same world frame. */}
<OpeningGuides3DLayer /> <OpeningGuides3DLayer />
@@ -13,7 +13,8 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
import { import {
distanceSquared, distanceSquared,
findWallSnapTarget, findWallSnapTarget,
@@ -51,10 +52,15 @@ type WallSplitIntersection = {
} }
export function getSegmentGridStep(): number { export function getSegmentGridStep(): number {
return useEditor.getState().gridSnapStep // 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(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0
} }
export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number { export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
if (step <= 0) return value
return Math.round(value / step) * step return Math.round(value / step) * step
} }
@@ -404,6 +410,11 @@ export function createWallOnCurrentLevel(
let resolvedStart = start let resolvedStart = start
let resolvedEnd = end 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 endIntersection = findWallIntersection(resolvedEnd, workingWalls)
const splitEnd = splitWallIfNeeded( const splitEnd = splitWallIfNeeded(
endIntersection, endIntersection,
@@ -431,6 +442,7 @@ export function createWallOnCurrentLevel(
workingWalls = splitStart.walls workingWalls = splitStart.walls
resolvedStart = splitStart.point resolvedStart = splitStart.point
} }
}
if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) { if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
return null return null
@@ -13,7 +13,7 @@ import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } fro
import { EDITOR_LAYER } from './../../../lib/constants' import { EDITOR_LAYER } from './../../../lib/constants'
import { sfxEmitter } from './../../../lib/sfx-bus' import { sfxEmitter } from './../../../lib/sfx-bus'
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap' import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
import useEditor from './../../../store/use-editor' import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
const Y_OFFSET = 0.02 const Y_OFFSET = 0.02
@@ -65,7 +65,6 @@ export const ZoneTool: React.FC = () => {
const pointsRef = useRef<Array<[number, number]>>([]) const pointsRef = useRef<Array<[number, number]>>([])
const previousSnappedPointRef = useRef<[number, number] | null>(null) const previousSnappedPointRef = useRef<[number, number] | null>(null)
const levelYRef = useRef(0) // Track current level Y position const levelYRef = useRef(0) // Track current level Y position
const shiftPressed = useRef(false)
const currentLevelId = useViewer((state) => state.selection.levelId) const currentLevelId = useViewer((state) => state.selection.levelId)
const setTool = useEditor((state) => state.setTool) const setTool = useEditor((state) => state.setTool)
@@ -86,21 +85,19 @@ export const ZoneTool: React.FC = () => {
mainLineRef.current.geometry = new BufferGeometry() mainLineRef.current.geometry = new BufferGeometry()
closingLineRef.current.geometry = new BufferGeometry() closingLineRef.current.geometry = new BufferGeometry()
// 15° angle snap from the last vertex by default. Shift bypasses all snap. // Snapping follows the active mode (zone resolves to the 'wall' context):
// Distance snaps along the ray so the vertex lands on // `angles` locks the ray to 15° from the last vertex, `grid` quantizes the
// grid-multiple lengths without leaving the ray. // distance along it, `lines` / `off` leave the raw cursor. No held-Shift
// bypass — Shift cycles the mode (see interaction-scope.md).
const snapDraftPoint = ( const snapDraftPoint = (
lastPoint: [number, number], lastPoint: [number, number],
gridPoint: [number, number], _gridPoint: [number, number],
rawPoint: [number, number], rawPoint: [number, number],
): [number, number] => { ): [number, number] => {
if (shiftPressed.current) return rawPoint const angleStep = isAngleSnapActive() ? DEFAULT_ANGLE_STEP : 0
const [x, z] = snapPointAlongAngleRay( const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
lastPoint, if (angleStep === 0 && gridStep === 0) return rawPoint
rawPoint, const [x, z] = snapPointAlongAngleRay(lastPoint, rawPoint, angleStep, gridStep)
DEFAULT_ANGLE_STEP,
useEditor.getState().gridSnapStep,
)
return [x, z] return [x, z]
} }
@@ -172,15 +169,16 @@ export const ZoneTool: React.FC = () => {
if (!cursorRef.current) return if (!cursorRef.current) return
// World-grid snap projected into building-local; rotated buildings // World-grid snap projected into building-local; rotated buildings
// used to pull the snap off the visible grid lines. // used to pull the snap off the visible grid lines. Grid quantize only
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true // in grid mode; off / lines / angles leave the raw cursor for the first
const [gridX, gridZ] = bypassSnap // vertex (later vertices snap along the ray in `snapDraftPoint`).
? [event.localPosition[0], event.localPosition[2]] const [gridX, gridZ] = isGridSnapActive()
: snapWorldXZForActiveBuilding( ? snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
).local ).local
: [event.localPosition[0], event.localPosition[2]]
cursorPosition = [gridX, gridZ] cursorPosition = [gridX, gridZ]
rawCursorPosition = [event.localPosition[0], event.localPosition[2]] rawCursorPosition = [event.localPosition[0], event.localPosition[2]]
levelYRef.current = event.localPosition[1] levelYRef.current = event.localPosition[1]
@@ -191,9 +189,10 @@ export const ZoneTool: React.FC = () => {
? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition) ? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
: cursorPosition : cursorPosition
// Play snap sound when the snapped position changes during drawing // Play snap sound when the snapped position changes during drawing — only
// when a quantizing mode is active (off / lines move continuously).
if ( if (
!bypassSnap && (isGridSnapActive() || isAngleSnapActive()) &&
pointsRef.current.length > 0 && pointsRef.current.length > 0 &&
previousSnappedPointRef.current && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || (displayPoint[0] !== previousSnappedPointRef.current[0] ||
@@ -211,14 +210,13 @@ export const ZoneTool: React.FC = () => {
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return if (!currentLevelId) return
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true const [gridX, gridZ] = isGridSnapActive()
const [gridX, gridZ] = bypassSnap ? snapWorldXZForActiveBuilding(
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
).local ).local
: [event.localPosition[0], event.localPosition[2]]
let clickPoint: [number, number] = [gridX, gridZ] let clickPoint: [number, number] = [gridX, gridZ]
// Snap to the 15° ray from the last point // Snap to the 15° ray from the last point
@@ -270,28 +268,12 @@ export const ZoneTool: React.FC = () => {
} }
} }
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
const onWindowBlur = () => {
shiftPressed.current = false
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
// Subscribe to events // Subscribe to events
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick) emitter.on('grid:double-click', onGridDoubleClick)
return () => { return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
@@ -9,7 +9,7 @@ import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { CameraActions } from './camera-actions' import { CameraActions } from './camera-actions'
import { ControlModes } from './control-modes' import { ControlModes } from './control-modes'
import { GridSnapControl, SecondaryToggles } from './view-toggles' import { SecondaryToggles } from './view-toggles'
// Mobile bottom offset matches the viewer's overlap behind the sheet's // Mobile bottom offset matches the viewer's overlap behind the sheet's
// rounded corners (SHEET_OVERLAP_PX in editor-layout-mobile) so the menu sits // rounded corners (SHEET_OVERLAP_PX in editor-layout-mobile) so the menu sits
@@ -57,9 +57,8 @@ export function ActionMenu({ className }: { className?: string }) {
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<ControlModes /> <ControlModes />
</div> </div>
{/* Row 2: grid snap + secondary toggles (orbit + top view hidden) */} {/* Row 2: secondary toggles (orbit + top view hidden) */}
<div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1"> <div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1">
<GridSnapControl />
<SecondaryToggles /> <SecondaryToggles />
</div> </div>
</div> </div>
@@ -67,7 +66,6 @@ export function ActionMenu({ className }: { className?: string }) {
<div className="flex items-center justify-center gap-1 px-2 py-1.5"> <div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes /> <ControlModes />
<div className="mx-1 h-5 w-px bg-border" /> <div className="mx-1 h-5 w-px bg-border" />
<GridSnapControl />
<SecondaryToggles /> <SecondaryToggles />
<div className="mx-1 h-5 w-px bg-border" /> <div className="mx-1 h-5 w-px bg-border" />
<CameraActions /> <CameraActions />
@@ -1,6 +1,5 @@
'use client' 'use client'
import { Icon } from '@iconify/react'
import { import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
@@ -16,23 +15,17 @@ import { useShallow } from 'zustand/react/shallow'
import { getLevelDisplayName } from '@pascal-app/core' import { getLevelDisplayName } from '@pascal-app/core'
import { createLocalGuideImage } from '../../../lib/local-guide-image' import { createLocalGuideImage } from '../../../lib/local-guide-image'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { useUploadStore } from '../../../store/use-upload' import { useUploadStore } from '../../../store/use-upload'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover' import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
import { ActionButton } from './action-button' import { ActionButton } from './action-button'
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif' const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
const REFERENCES_EMPTY_TEXT = const REFERENCES_EMPTY_TEXT =
'Upload GLB meshes as scan references or blueprint images as guide references.' 'Upload GLB meshes as scan references or blueprint images as guide references.'
function formatGridSnapStep(step: GridSnapStep) {
return step.toFixed(2)
}
// ── Helper: get guide images for the current level ────────────────────────── // ── Helper: get guide images for the current level ──────────────────────────
function useLevelGuides(): GuideNode[] { function useLevelGuides(): GuideNode[] {
@@ -353,70 +346,6 @@ function GuidesControl() {
) )
} }
// ── Grid snap toggle ────────────────────────────────────────────────────────
function GridSnapControl() {
const [isOpen, setIsOpen] = useState(false)
const gridSnapStep = useEditor((state) => state.gridSnapStep)
const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
aria-expanded={isOpen}
aria-label={`Grid snap: ${formatGridSnapStep(gridSnapStep)}`}
className={cn(
'flex h-11 w-11 flex-col items-center justify-center rounded-lg text-muted-foreground transition-all hover:bg-white/5 hover:text-foreground',
isOpen && 'bg-white/10 text-foreground',
)}
type="button"
>
<Icon height={16} icon="lucide:grid-2x2" width={16} />
<span className="mt-1 font-medium text-[9px] leading-none">
{formatGridSnapStep(gridSnapStep)}
</span>
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
</Tooltip>
<PopoverContent
align="center"
className="w-36 rounded-xl border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
side="top"
sideOffset={14}
>
<div className="space-y-1">
{GRID_SNAP_STEPS.map((step) => {
const isActive = step === gridSnapStep
return (
<button
className={cn(
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
isActive && 'bg-white/10 text-foreground',
)}
key={step}
onClick={() => {
setGridSnapStep(step)
setIsOpen(false)
}}
type="button"
>
<span>{formatGridSnapStep(step)}</span>
{isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
</button>
)
})}
</div>
</PopoverContent>
</Popover>
)
}
// ── Scans toggle + dropdown ───────────────────────────────────────────────── // ── Scans toggle + dropdown ─────────────────────────────────────────────────
function ScansControl() { function ScansControl() {
@@ -1014,8 +943,6 @@ function RiserControl() {
// ── Exports ───────────────────────────────────────────────────────────────── // ── Exports ─────────────────────────────────────────────────────────────────
export { GridSnapControl }
export function SecondaryToggles() { export function SecondaryToggles() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -1027,7 +954,6 @@ export function SecondaryToggles() {
export function ViewToggles() { export function ViewToggles() {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<GridSnapControl />
<ScansControl /> <ScansControl />
<GuidesControl /> <GuidesControl />
<ReferenceFloorControl /> <ReferenceFloorControl />
@@ -1,32 +1,19 @@
import { ShortcutToken } from '../primitives/shortcut-token' import { ContextualHelperPanel } from './contextual-helper-panel'
interface BuildingHelperProps { interface BuildingHelperProps {
showRotate?: boolean showRotate?: boolean
} }
// Rotate is one hint with both keys (R / T) — never two separate
// counterclockwise / clockwise rows — to match every other placement helper.
export function BuildingHelper({ showRotate }: BuildingHelperProps) { export function BuildingHelper({ showRotate }: BuildingHelperProps) {
return ( return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md"> <ContextualHelperPanel
<div className="flex items-center gap-2 text-sm"> hints={[
<ShortcutToken value="Left click" /> { keys: ['Left click'], label: 'Place building' },
<span className="text-muted-foreground">Place building</span> ...(showRotate ? [{ keys: ['R', 'T'], label: 'Rotate' }] : []),
</div> { keys: ['Esc'], label: 'Cancel' },
{showRotate && ( ]}
<> />
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="R" />
<span className="text-muted-foreground">Rotate counterclockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="T" />
<span className="text-muted-foreground">Rotate clockwise</span>
</div>
</>
)}
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Esc" />
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
) )
} }
@@ -1,35 +1,288 @@
import { Icon } from '@iconify/react'
import { Fragment } from 'react'
import {
CONTINUATION_PROFILES,
type ContinuationContext,
} from '../../../lib/continuation'
import type { ContextualShortcutHint } from '../../../lib/contextual-help' import type { ContextualShortcutHint } from '../../../lib/contextual-help'
import { hasActivePaintMaterial } from '../../../lib/material-paint'
import { paintScopeLabel, type PaintScope } from '../../../lib/paint-scope'
import {
cycleSnappingModeIn,
resolveSnapFlags,
type SnapContext,
} from '../../../lib/snapping-mode'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
import { ShortcutToken } from '../primitives/shortcut-token' import { ShortcutToken } from '../primitives/shortcut-token'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
// One muted container holds every row — passive key hints and interactive chips
// alike — so the HUD reads as a single panel, not a stack of floating pills. The
// background is near-opaque (`bg-background/95`) with a single backdrop blur so
// active rows stay readable over the 3D scene even while a modifier is held.
// A 2-track grid: column 1 sizes to `max-content` (the widest key across ALL
// rows), column 2 (`1fr`) is the label. Every row is a subgrid sharing those
// tracks, so labels align even when keys differ in width (⌘ vs Shift) or wrap to
// two lines. Near-opaque bg + single backdrop blur keeps active rows readable.
const CONTAINER_CLASS =
'pointer-events-none fixed top-1/2 right-4 z-40 grid max-w-[260px] -translate-y-1/2 grid-cols-[max-content_1fr] gap-x-2.5 gap-y-1.5 rounded-lg border border-border bg-background/95 px-3 py-2.5 shadow-lg backdrop-blur-md'
const TOKEN_CLASS = 'h-5 px-1.5 text-[10px]'
// Each row spans both columns as its own subgrid, inheriting the container's
// tracks so its key/label cells land on the shared column lines.
const ROW_CLASS = 'col-span-2 grid grid-cols-subgrid'
// The key cell (column 1). `items-center` centres the token; the row's
// `items-start` keeps it on the label's first line when the label wraps.
const KEY_CELL_CLASS = 'flex items-center gap-1'
function ShortcutSequence({ keys }: { keys: string[] }) { function ShortcutSequence({ keys }: { keys: string[] }) {
return ( return (
<div className="flex flex-wrap items-center gap-0.5"> <div className={KEY_CELL_CLASS}>
{keys.map((key, index) => ( {keys.map((key, index) => (
<div className="flex items-center gap-0.5" key={`${key}-${index}`}> <Fragment key={`${key}-${index}`}>
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">+</span> : null} {index > 0 ? <span className="text-[9px] text-muted-foreground/70">/</span> : null}
<ShortcutToken className="h-5 px-1.5 text-[10px]" value={key} /> <ShortcutToken className={TOKEN_CLASS} value={key} />
</div> </Fragment>
))} ))}
</div> </div>
) )
} }
export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) { // Shared single-line chip row (key cell + icon/label cell). Rendered either as a
if (hints.length === 0) return null // passive row (no `onClick`) or a clickable button. The outer container is
// `pointer-events-none`, so clickable chips opt back in.
function ChipRow({
ariaLabel,
icon,
label,
onClick,
shortcut,
tooltip,
}: {
ariaLabel?: string
icon?: string
label: string
onClick?: () => void
shortcut?: string
tooltip?: string
}) {
const body = (
<>
<span className={KEY_CELL_CLASS}>
{shortcut ? <ShortcutToken className={TOKEN_CLASS} value={shortcut} /> : null}
</span>
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground text-xs">
{icon ? <Icon className="shrink-0" height={13} icon={icon} width={13} /> : null}
<span className="truncate">{label}</span>
</span>
</>
)
if (!onClick) {
return <div className={cn(ROW_CLASS, 'items-center')}>{body}</div>
}
const button = (
<button
aria-label={ariaLabel ?? label}
className={cn(
ROW_CLASS,
'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60',
)}
onClick={onClick}
type="button"
>
{body}
</button>
)
if (!tooltip) return button
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="left">{tooltip}</TooltipContent>
</Tooltip>
)
}
const SNAPPING_MODE_ICONS = {
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]!
}
// The active interaction's snapping controls, scoped to its context (wall / item
// / polygon) so each action shows only the modes that make sense for it.
function SnappingChips({ context }: { context: SnapContext }) {
const snappingMode = useEditor((s) => s.snappingModeByContext[context])
const setSnappingMode = useEditor((s) => s.setSnappingMode)
const gridSnapStep = useEditor((s) => s.gridSnapStep)
const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
const gridActive = resolveSnapFlags(snappingMode).grid
return ( return (
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col gap-1.5 rounded-lg border border-border bg-background/95 px-3 py-2.5 shadow-lg backdrop-blur-md"> <>
<ChipRow
ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
icon={SNAPPING_MODE_ICONS[snappingMode]}
label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
onClick={() => setSnappingMode(context, cycleSnappingModeIn(context, snappingMode))}
shortcut="Shift"
tooltip="Snapping mode — click or press Shift to cycle"
/>
{gridActive ? (
<ChipRow
ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
label={`Grid: ${gridSnapStep.toFixed(2)} m`}
onClick={() => setGridSnapStep(nextGridSnapStep(gridSnapStep))}
shortcut="Ctrl"
tooltip="Grid step — click or tap Ctrl to cycle"
/>
) : null}
</>
)
}
function ContinuationChip({ context }: { context: ContinuationContext }) {
const mode = useEditor((s) => s.getContinuation(context))
const cycleContinuation = useEditor((s) => s.cycleContinuation)
const profile = CONTINUATION_PROFILES[context]
const label = profile.labels[mode] ?? mode
const icon = profile.icons[mode] ?? 'lucide:repeat'
return (
<ChipRow
ariaLabel={`Continuation: ${label}`}
icon={icon}
label={label}
onClick={() => cycleContinuation(context)}
shortcut="C"
tooltip="Continuation — click or press C to cycle"
/>
)
}
const PAINT_SCOPE_ICONS: Record<PaintScope, string> = {
single: 'lucide:square',
object: 'lucide:box',
matching: 'lucide:copy',
room: 'lucide:scan',
}
// The painter's application-scope chip. Driven entirely by the hovered node's
// derived `paintHover` (scopes + labels), so it works for any kind without a
// per-target table.
function PaintScopeChip() {
// What the cursor is over (that's what the next click paints). `null` when not
// over a paintable surface — including an item with no slots.
const paintHover = useEditor((s) => s.paintHover)
const paintScope = useEditor((s) => s.paintScope)
const cyclePaintScope = useEditor((s) => s.cyclePaintScope)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
const paintEraser = useEditor((s) => s.paintEraser)
// Nothing to paint with yet (no material picked, not erasing) → the first step
// is choosing a material, so say that before anything about scope or hovering.
if (!(paintEraser || hasActivePaintMaterial(activePaintMaterial))) {
return <ChipRow icon="lucide:palette" label="Select a material to paint" />
}
// Not over anything paintable → guide the user to hover, still teaching Shift.
if (!paintHover) {
return (
<ChipRow icon="lucide:mouse-pointer-click" label="Hover a surface to paint" shortcut="Shift" />
)
}
const { scopes } = paintHover
// A scope carried over from another node (the mode is global) falls back to
// the narrowest for both display and — via the apply-time resolver — behaviour.
const effective: PaintScope = scopes.includes(paintScope) ? paintScope : 'single'
// Paintable but with no scope choice (roof, a one-slot node, …) → a passive
// row that still names the surface, so the user always sees what they'll paint.
if (scopes.length <= 1) {
return (
<ChipRow
icon={PAINT_SCOPE_ICONS[effective]}
label={`Paint: ${paintScopeLabel(effective, paintHover)}`}
/>
)
}
return (
<ChipRow
ariaLabel={`Paint scope: ${paintScopeLabel(effective, paintHover)}`}
icon={PAINT_SCOPE_ICONS[effective]}
label={`Paint: ${paintScopeLabel(effective, paintHover)}`}
onClick={() => cyclePaintScope()}
shortcut="Shift"
tooltip="Paint scope — click or press Shift to cycle"
/>
)
}
export function ContextualHelperPanel({
hints,
snapContext = null,
showPaintScope = false,
continuationContext = null,
}: {
hints: ContextualShortcutHint[]
// The active snapping context drives the snapping chips (which mode set). Null
// → no snapping chips for this interaction.
snapContext?: SnapContext | null
showPaintScope?: boolean
continuationContext?: ContinuationContext | null
}) {
if (hints.length === 0 && !snapContext && !showPaintScope && !continuationContext)
return null
return (
<div className={CONTAINER_CLASS}>
{snapContext ? <SnappingChips context={snapContext} /> : null}
{continuationContext ? <ContinuationChip context={continuationContext} /> : null}
{showPaintScope ? <PaintScopeChip /> : null}
{hints.map((hint) => ( {hints.map((hint) => (
<div <div
className={cn( className={cn(ROW_CLASS, 'items-start', hint.active && 'rounded-md bg-primary/10')}
'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',
)}
key={`${hint.keys.join('+')}:${hint.label}`} key={`${hint.keys.join('+')}:${hint.label}`}
> >
<ShortcutSequence keys={hint.keys} /> <ShortcutSequence keys={hint.keys} />
<span className="min-w-0 text-muted-foreground text-xs leading-snug">{hint.label}</span> <div className="min-w-0">
<div
className={cn(
'text-xs leading-5',
hint.active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{hint.label}
</div>
{hint.subtitle ? (
<div className="text-[10px] text-muted-foreground/70 leading-snug">
{hint.subtitle}
</div>
) : null}
</div>
</div> </div>
))} ))}
</div> </div>
@@ -10,15 +10,43 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useIsMobile } from '../../../hooks/use-mobile' import { useIsMobile } from '../../../hooks/use-mobile'
import { resolveSelectModeHelpHints } from '../../../lib/contextual-help' import {
type ContextualShortcutHint,
ROTATE_HANDLE_DRAG_LABEL,
resolveRotateHandleHelpHints,
resolveSelectModeHelpHints,
} from '../../../lib/contextual-help'
import { continuationContextOf } from '../../../lib/continuation'
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation' import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
import useEditor from '../../../store/use-editor' import type { ReshapeKind } from '../../../lib/interaction/scope'
import { isFreshPlacementMetadata } from '../../../lib/placement-metadata'
import { snapContextOf } from '../../../lib/snapping-mode'
import useEditor, { getActiveContinuationContext } from '../../../store/use-editor'
import useInteractionScope, {
useActiveHandleDrag,
useMovingNode,
} from '../../../store/use-interaction-scope'
import { BuildingHelper } from './building-helper' import { BuildingHelper } from './building-helper'
import { ContextualHelperPanel } from './contextual-helper-panel' import { ContextualHelperPanel } from './contextual-helper-panel'
import { ItemHelper } from './item-helper' import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-helper' import { RegisteredToolHelper } from './registered-tool-helper'
import { RoofHelper } from './roof-helper' import { RoofHelper } from './roof-helper'
// Reshaping a selected node's geometry (endpoint / curve / polygon corner). The
// snapping chip is the main control; these just name the gesture + Esc.
function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] {
const action =
reshape === 'curve'
? 'Curve'
: reshape === 'endpoint'
? 'Move endpoint'
: 'Move corner'
return [
{ keys: ['Drag'], label: action },
{ keys: ['Esc'], label: 'Cancel' },
]
}
type ActiveModifierKeys = { type ActiveModifierKeys = {
command: boolean command: boolean
shift: boolean shift: boolean
@@ -61,7 +89,9 @@ function useActiveModifierKeys(): ActiveModifierKeys {
export function HelperManager() { export function HelperManager() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool) const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode) const scope = useInteractionScope((s) => s.scope)
const movingNode = useMovingNode()
const activeHandleDrag = useActiveHandleDrag()
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const modifiers = useActiveModifierKeys() const modifiers = useActiveModifierKeys()
@@ -72,6 +102,23 @@ export function HelperManager() {
.filter((node): node is AnyNode => node !== undefined), .filter((node): node is AnyNode => node !== undefined),
), ),
) )
// The snapping context for whatever's active (wall / item / polygon) — drives
// which snapping chips the HUD shows, derived once and shared by every branch.
const snapContext = useMemo(
() =>
snapContextOf({
scope,
mode,
tool,
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
}),
[scope, mode, tool],
)
const continuationContext = useMemo(
() => getActiveContinuationContext(),
[scope, mode, tool],
)
const selectModeHints = useMemo(() => { const selectModeHints = useMemo(() => {
const single = selectedNodes.length === 1 ? selectedNodes[0] : null const single = selectedNodes.length === 1 ? selectedNodes[0] : null
const mepSelection = const mepSelection =
@@ -93,32 +140,78 @@ export function HelperManager() {
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null if (isMobile) return null
// Rotating a node via its in-world gizmo: advertise Shift = free rotation,
// the same angle-step bypass wall drafting exposes. Takes priority over the
// idle select-mode hints since a handle drag is the active interaction.
if (activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL) {
return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
}
// Reshaping a node's geometry (endpoint / curve / polygon corner). Checked
// before the select branch so the idle "drag selected / add objects" hints
// never leak over an in-progress reshape — and it gets its own snapping chip.
if (scope.kind === 'reshaping') {
return <ContextualHelperPanel hints={reshapingHints(scope.reshape)} snapContext={snapContext} />
}
if (movingNode) { if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate /> if (movingNode.type === 'building') return <BuildingHelper showRotate />
return <ItemHelper shiftPressed={modifiers.shift} showEsc /> // A fresh placement (e.g. a positioned preset like a shelf) advertises its
// once/repeat continuation, exactly like the GLB item tool — but an existing
// node being *moved* is not a placement, so it gets no continuation chip.
const movingContinuationContext = isFreshPlacementMetadata(movingNode.metadata)
? continuationContextOf(movingNode.type)
: null
// Force-place only makes sense for kinds that collision-validate their drop;
// structural kinds (wall/slab/…) never reject, so don't advertise Alt.
return (
<ItemHelper
continuationContext={movingContinuationContext}
showEsc
showForce={nodeRegistry.get(movingNode.type)?.snapProfile !== 'structural'}
snapContext={snapContext}
/>
)
} }
// Paint mode advertises (and cycles, via Shift) the application scope — the
// only contextual control here. The chip hides itself for targets that only
// paint one surface, so this renders nothing until a scoped target is active.
if (mode === 'material-paint') { if (mode === 'material-paint') {
return null return <ContextualHelperPanel hints={[]} showPaintScope />
} }
if (mode === 'select') { // Idle select only — an active scope (handle-drag, box-select, …) must not show
// the idle selection hints.
if (mode === 'select' && scope.kind === 'idle') {
return <ContextualHelperPanel hints={selectModeHints} /> return <ContextualHelperPanel hints={selectModeHints} />
} }
// Registry-first: kinds with `def.toolHints` render through the generic // Legacy fallback — only `roof` remains because it hasn't migrated to
// `RegisteredToolHelper`. Today that covers ceiling / door / fence / // `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the
// item / shelf / slab / spawn / wall / window. // generic tool branch so the snap-context fallback below doesn't capture it
// and drop its bespoke `RoofHelper` hints. When roof migrates, this deletes.
if (tool === 'roof') return <RoofHelper snapContext={snapContext} />
// Registry-first: a kind renders the generic `RegisteredToolHelper` when it
// declares `def.toolHints`, OR whenever its draft resolves to a snap /
// continuation context — so a snappable tool with NO hand-written hints (e.g.
// `zone`) still advertises the snapping chip it already honors (Shift = cycle).
// `RegisteredToolHelper` self-hides when there's genuinely nothing to show.
if (tool) { if (tool) {
const def = nodeRegistry.get(tool) const def = nodeRegistry.get(tool)
if (def?.toolHints && def.toolHints.length > 0) { const hints = def?.toolHints ?? []
return <RegisteredToolHelper hints={def.toolHints} shiftPressed={modifiers.shift} /> if (hints.length > 0 || snapContext || continuationContext) {
return (
<RegisteredToolHelper
continuationContext={continuationContext}
hints={hints}
shiftPressed={modifiers.shift}
snapContext={snapContext}
/>
)
} }
} }
// Legacy fallback — only `roof` remains because it hasn't migrated to
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof
// migrates, this switch deletes outright.
if (tool === 'roof') return <RoofHelper shiftPressed={modifiers.shift} />
return null return null
} }
@@ -1,24 +1,36 @@
import type { ContinuationContext } from '../../../lib/continuation'
import type { SnapContext } from '../../../lib/snapping-mode'
import { ContextualHelperPanel } from './contextual-helper-panel' import { ContextualHelperPanel } from './contextual-helper-panel'
interface ItemHelperProps { interface ItemHelperProps {
showEsc?: boolean showEsc?: boolean
shiftPressed?: boolean snapContext?: SnapContext | null
// Whether to advertise Alt = force-place. Only meaningful for kinds that
// collision-validate their drop (structural kinds never reject, so it's hidden).
showForce?: boolean
// Set for a fresh point-kind placement (e.g. a positioned preset) so the
// once/repeat continuation chip shows; null for an existing-node move.
continuationContext?: ContinuationContext | null
} }
export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) { // Snapping mode is the chip on the right (Shift cycles it), so it's not repeated
// as a key hint. Rotate is the two keys; Alt forces an invalid (red) drop.
export function ItemHelper({
showEsc,
snapContext,
showForce,
continuationContext = null,
}: ItemHelperProps) {
return ( return (
<ContextualHelperPanel <ContextualHelperPanel
continuationContext={continuationContext}
hints={[ hints={[
{ keys: ['Left click'], label: 'Place item' }, { keys: ['Left click'], label: 'Place' },
{ keys: ['R'], label: 'Rotate counterclockwise' }, { keys: ['R', 'T'], label: 'Rotate' },
{ keys: ['T'], label: 'Rotate clockwise' }, ...(showForce ? [{ keys: ['Alt'], label: 'Force place' }] : []),
{
keys: ['Shift'],
label: shiftPressed ? 'Guided constraints bypassed' : 'Free place',
active: shiftPressed,
},
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' }, { keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
]} ]}
snapContext={snapContext}
/> />
) )
} }
@@ -1,4 +1,7 @@
import type { ToolHint } from '@pascal-app/core' import type { ToolHint } from '@pascal-app/core'
import type { ContinuationContext } from '../../../lib/continuation'
import type { SnapContext } from '../../../lib/snapping-mode'
import useEditor from '../../../store/use-editor'
import { ContextualHelperPanel } from './contextual-helper-panel' import { ContextualHelperPanel } from './contextual-helper-panel'
/** /**
@@ -13,19 +16,40 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
export function RegisteredToolHelper({ export function RegisteredToolHelper({
hints, hints,
shiftPressed = false, shiftPressed = false,
snapContext = null,
continuationContext = null,
}: { }: {
hints: ToolHint[] hints: ToolHint[]
shiftPressed?: boolean shiftPressed?: boolean
snapContext?: SnapContext | null
continuationContext?: ContinuationContext | null
}) { }) {
if (hints.length === 0) return null // Live vertex count of an in-progress polygon draft, so hints gated on a
// minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
const draftVertexCount = useEditor((s) => s.draftVertexCount)
// The snapping chip (when a context is active) already shows Shift = cycle, so
// drop the redundant 'Cycle snapping mode' tool hint to avoid a double pill;
// also hide draft-gated hints until the draft is far enough along.
const visible = hints.filter(
(hint) =>
!(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
)
if (visible.length === 0 && !snapContext && !continuationContext) return null
return ( return (
<ContextualHelperPanel <ContextualHelperPanel
hints={hints.map((hint) => ({ hints={visible.map((hint) => {
// Shift is a per-kind bypass for opening / zone / duct placement ("Free
// place", "Free angle", …) — those flip to a bypassed state while held.
const isBypassHint = hint.key === 'Shift'
return {
keys: [hint.key], keys: [hint.key],
label: label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label,
shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label, active: shiftPressed && isBypassHint,
active: shiftPressed && hint.key === 'Shift', }
}))} })}
continuationContext={continuationContext}
snapContext={snapContext}
/> />
) )
} }
@@ -1,17 +1,14 @@
import type { SnapContext } from '../../../lib/snapping-mode'
import { ContextualHelperPanel } from './contextual-helper-panel' import { ContextualHelperPanel } from './contextual-helper-panel'
export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) { export function RoofHelper({ snapContext }: { snapContext?: SnapContext | null }) {
return ( return (
<ContextualHelperPanel <ContextualHelperPanel
hints={[ hints={[
{ keys: ['Left click'], label: 'Set corner' }, { keys: ['Left click'], label: 'Set corner' },
{
keys: ['Shift'],
label: shiftPressed ? 'Guided constraints bypassed' : 'Free corner',
active: shiftPressed,
},
{ keys: ['Esc'], label: 'Cancel' }, { keys: ['Esc'], label: 'Cancel' },
]} ]}
snapContext={snapContext}
/> />
) )
} }
@@ -53,16 +53,6 @@ export function ItemCatalog({
}) })
})() })()
const categoryItems = filteredItems
// Auto-select first item if current selection is not in the filtered list
useEffect(() => {
const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
if (!isCurrentItemInCategory && categoryItems.length > 0) {
setSelectedItem(categoryItems[0] as AssetInput)
}
}, [categoryItems, selectedItem?.src, setSelectedItem])
if (filteredItems.length === 0 && emptyState) { if (filteredItems.length === 0 && emptyState) {
return <>{emptyState}</> return <>{emptyState}</>
} }
@@ -22,6 +22,17 @@ const MOUSE_SHORTCUTS = {
}, },
} as const } as const
// The platform-agnostic command modifier. Both Cmd and Ctrl bind the action; we
// render the symbol for the *current* device so the hint reads native (⌘ on Mac,
// Ctrl elsewhere) without implying only one of them works.
const COMMAND_VALUES = new Set(['Cmd/Ctrl', 'Cmd', 'Command', 'Meta'])
// Resolved once on the client at module load — the editor HUD is client-only, so
// there's no server render to mismatch against. `navigator.platform` is enough
// here and matches the detection used elsewhere (floorplan rotate hint).
const IS_MAC =
typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC')
type ShortcutTokenProps = React.ComponentProps<'kbd'> & { type ShortcutTokenProps = React.ComponentProps<'kbd'> & {
value: string value: string
displayValue?: string displayValue?: string
@@ -30,16 +41,19 @@ type ShortcutTokenProps = React.ComponentProps<'kbd'> & {
function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) { function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) {
const mouseShortcut = const mouseShortcut =
value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null
const isCommand = COMMAND_VALUES.has(value)
const commandDisplay = IS_MAC ? '⌘' : 'Ctrl'
const commandLabel = IS_MAC ? 'Command' : 'Control'
return ( return (
<kbd <kbd
aria-label={mouseShortcut?.label ?? displayValue ?? value} aria-label={mouseShortcut?.label ?? (isCommand ? commandLabel : (displayValue ?? value))}
className={cn( className={cn(
'inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground', 'inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground',
mouseShortcut && 'justify-center px-1.5', mouseShortcut && 'justify-center px-1.5',
className, className,
)} )}
title={mouseShortcut?.label ?? value} title={mouseShortcut?.label ?? (isCommand ? commandLabel : value)}
{...props} {...props}
> >
{mouseShortcut ? ( {mouseShortcut ? (
@@ -54,6 +68,10 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok
/> />
<span className="sr-only">{mouseShortcut.label}</span> <span className="sr-only">{mouseShortcut.label}</span>
</> </>
) : isCommand ? (
// The ⌘ glyph reads small next to letters at the same font size, so bump
// it up a touch on Mac. "Ctrl" stays at the token's normal size.
<span className={IS_MAC ? 'text-[13px] leading-none' : undefined}>{commandDisplay}</span>
) : ( ) : (
(displayValue ?? value) (displayValue ?? value)
)} )}
@@ -183,7 +183,6 @@ export function SettingsPanel({
const clearScene = useScene((state) => state.clearScene) const clearScene = useScene((state) => state.clearScene)
const resetSelection = useViewer((state) => state.resetSelection) const resetSelection = useViewer((state) => state.resetSelection)
const exportScene = useViewer((state) => state.exportScene) const exportScene = useViewer((state) => state.exportScene)
const showGrid = useViewer((state) => state.showGrid)
const shadows = useViewer((state) => state.shadows) const shadows = useViewer((state) => state.shadows)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
@@ -331,16 +330,6 @@ export function SettingsPanel({
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)} onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
/> />
</div> </div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Show Grid</div>
<div className="text-muted-foreground text-xs">Visible only in the editor</div>
</div>
<Switch
checked={showGrid}
onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
/>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="font-medium text-sm">Shadows</div> <div className="font-medium text-sm">Shadows</div>
@@ -4,13 +4,18 @@ import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { resolveOverlayPolicy } from '../lib/interaction/overlay-policy'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
import useInteractionScope from '../store/use-interaction-scope'
export const ViewerZoneSystem = () => { export const ViewerZoneSystem = () => {
useFrame(() => { useFrame(() => {
const { levelId, zoneId } = useViewer.getState().selection const { levelId, zoneId } = useViewer.getState().selection
const structureLayer = useEditor.getState().structureLayer const structureLayer = useEditor.getState().structureLayer
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
// During any active interaction zone labels step back entirely (Sims-light).
const zoneLabelsHidden =
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
sceneRegistry.byType.zone!.forEach((id) => { sceneRegistry.byType.zone!.forEach((id) => {
const obj = sceneRegistry.nodes.get(id) const obj = sceneRegistry.nodes.get(id)
@@ -35,7 +40,7 @@ export const ViewerZoneSystem = () => {
}) })
// Labels: always visible on the current level (regardless of mode or zone selection) // Labels: always visible on the current level (regardless of mode or zone selection)
const showLabel = !!levelId && isOnSelectedLevel const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
const targetOpacity = showLabel ? '1' : '0' const targetOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${id}-label`) const labelEl = document.getElementById(`${id}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) { if (labelEl && labelEl.style.opacity !== targetOpacity) {
@@ -12,6 +12,7 @@ import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
import { getMovingNode } from '../store/use-interaction-scope'
const UP = new Vector3(0, 1, 0) const UP = new Vector3(0, 1, 0)
@@ -59,7 +60,7 @@ export function useCeilingEvents() {
const isActive = (): boolean => { const isActive = (): boolean => {
const ed = useEditor.getState() const ed = useEditor.getState()
if (ed.selectedItem?.attachTo === 'ceiling') return true if (ed.selectedItem?.attachTo === 'ceiling') return true
const moving = ed.movingNode const moving = getMovingNode()
return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling' return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling'
} }
+117 -13
View File
@@ -1,6 +1,7 @@
import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { steppedRotation } from '../components/tools/item/placement-math'
import { toggleDoorOpenState } from '../lib/door-interaction' import { toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { import {
@@ -9,7 +10,8 @@ import {
} from '../lib/scene-clipboard' } from '../lib/scene-clipboard'
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus' import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
import { toggleWindowOpenState } from '../lib/window-interaction' import { toggleWindowOpenState } from '../lib/window-interaction'
import useEditor from '../store/use-editor' import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
// Tools call this in their onCancel handler when they have an active mid-action to cancel, // Tools call this in their onCancel handler when they have an active mid-action to cancel,
// so that the global Escape handler knows not to also switch to select mode. // so that the global Escape handler knows not to also switch to select mode.
@@ -36,16 +38,77 @@ export const useKeyboard = ({
// global selection-based R/T handler must stand down to avoid double-firing. // global selection-based R/T handler must stand down to avoid double-firing.
const isPlacingOpening = () => { const isPlacingOpening = () => {
const ed = useEditor.getState() const ed = useEditor.getState()
if (ed.movingNode?.type === 'door' || ed.movingNode?.type === 'window') return true const moving = getMovingNode()
if (moving?.type === 'door' || moving?.type === 'window') return true
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window') return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
} }
// Shift cycles the snapping mode (and a clean-tap Ctrl the grid step)
// whenever there's an active snapping context — i.e. exactly when the HUD
// shows a snapping chip. That single source covers wall/fence/item drafting,
// every node move (including wall-hosted items + door/window openings, which
// now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
// never silently stop working. Force-place lives on Alt where a tool supports it.
const isSnappingCycleContext = () => getActiveSnapContext() != null
// A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
// between) cycles the grid step — same context as the Shift snapping-mode
// cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
// and is cleared the instant any other key fires, so chords like Ctrl+Z /
// Ctrl+C never cycle.
let ctrlTapClean = false
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
// Only a fresh, modifier-free press starts a clean-tap candidate;
// ignore key-repeat and presses already part of a combo.
ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey
} else {
// Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
// the clean tap.
ctrlTapClean = false
}
// Don't handle shortcuts if user is typing in an input // Don't handle shortcuts if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return return
} }
if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') {
// In paint mode Shift cycles the application scope (this surface →
// whole item / all matching / room) — the paint-mode analogue of the
// snapping-mode cycle below. The scope chip mirrors this key.
e.preventDefault()
useEditor.getState().cyclePaintScope()
sfxEmitter.emit('sfx:grid-snap')
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 === 'c' || e.key === 'C') &&
!e.repeat &&
!e.metaKey &&
!e.ctrlKey &&
!e.shiftKey &&
!e.altKey
) {
const context = getActiveContinuationContext()
if (context) {
e.preventDefault()
useEditor.getState().cycleContinuation(context)
sfxEmitter.emit('sfx:grid-snap')
return
}
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
_toolCancelConsumed = false _toolCancelConsumed = false
@@ -57,7 +120,9 @@ export const useKeyboard = ({
const currentPhase = useEditor.getState().phase const currentPhase = useEditor.getState().phase
const currentStructureLayer = useEditor.getState().structureLayer const currentStructureLayer = useEditor.getState().structureLayer
useEditor.getState().setEditingHole(null) useInteractionScope
.getState()
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
// From zone mode, return to structure select // From zone mode, return to structure select
if (currentPhase === 'structure' && currentStructureLayer === 'zones') { if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
@@ -91,6 +156,9 @@ export const useKeyboard = ({
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('furnish') useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('build') useEditor.getState().setMode('build')
// Set the item tool explicitly so the active tool never inherits a
// stale tool from a prior build session.
useEditor.getState().setTool('item')
useEditor.getState().setActiveSidebarPanel('items') useEditor.getState().setActiveSidebarPanel('items')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
@@ -98,6 +166,8 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('zones') useEditor.getState().setStructureLayer('zones')
useEditor.getState().setMode('build') useEditor.getState().setMode('build')
// Set the zone tool explicitly so it never inherits a stale tool.
useEditor.getState().setTool('zone')
} }
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault() e.preventDefault()
@@ -109,6 +179,9 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements') useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('build') useEditor.getState().setMode('build')
// Set the wall tool explicitly so B never inherits a stale tool
// (e.g. fence) left over from a prior build session.
useEditor.getState().setTool('wall')
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) { } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
@@ -227,14 +300,18 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
} else if (node && 'rotation' in node) { } else if (node && 'rotation' in node) {
e.preventDefault() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 // Round to the nearest 45° then step one increment (not a blind +45°).
// Handle different rotation types (number for roof, array for items/windows/doors)
if (typeof node.rotation === 'number') { if (typeof node.rotation === 'number') {
useScene.getState().updateNode(node.id, { rotation: node.rotation + ROTATION_STEP }) useScene
.getState()
.updateNode(node.id, { rotation: steppedRotation(node.rotation, 1) })
} else if (Array.isArray(node.rotation)) { } else if (Array.isArray(node.rotation)) {
useScene.getState().updateNode(node.id, { useScene.getState().updateNode(node.id, {
rotation: [node.rotation[0], node.rotation[1] + ROTATION_STEP, node.rotation[2]], rotation: [
node.rotation[0],
steppedRotation(node.rotation[1], 1),
node.rotation[2],
],
}) })
} }
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
@@ -260,13 +337,18 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
} else if (node && 'rotation' in node) { } else if (node && 'rotation' in node) {
e.preventDefault() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 // Round to the nearest 45° then step one increment back.
if (typeof node.rotation === 'number') { if (typeof node.rotation === 'number') {
useScene.getState().updateNode(node.id, { rotation: node.rotation - ROTATION_STEP }) useScene
.getState()
.updateNode(node.id, { rotation: steppedRotation(node.rotation, -1) })
} else if (Array.isArray(node.rotation)) { } else if (Array.isArray(node.rotation)) {
useScene.getState().updateNode(node.id, { useScene.getState().updateNode(node.id, {
rotation: [node.rotation[0], node.rotation[1] - ROTATION_STEP, node.rotation[2]], rotation: [
node.rotation[0],
steppedRotation(node.rotation[1], -1),
node.rotation[2],
],
}) })
} }
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
@@ -346,8 +428,30 @@ export const useKeyboard = ({
} }
} }
} }
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
const wasClean = ctrlTapClean
ctrlTapClean = false
if (!wasClean) return
// Same scope as the Shift snapping-mode cycle, and never while typing
// in an input.
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
if (!isSnappingCycleContext()) return
// Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
useEditor.getState().cycleGridSnapStep()
sfxEmitter.emit('sfx:grid-snap')
return
}
}
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [disabled, isVersionPreviewMode]) }, [disabled, isVersionPreviewMode])
return null return null
+42 -3
View File
@@ -155,6 +155,7 @@ export {
snapWallDraftPoint, snapWallDraftPoint,
snapWallDraftPointDetailed, snapWallDraftPointDetailed,
WALL_GRID_STEP, WALL_GRID_STEP,
WALL_JOIN_SNAP_RADIUS,
type WallDraftSnapKind, type WallDraftSnapKind,
type WallDraftSnapResult, type WallDraftSnapResult,
type WallPlanPoint, type WallPlanPoint,
@@ -230,6 +231,12 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard' export { markToolCancelConsumed } from './hooks/use-keyboard'
export { type Selection, useSelection } from './hooks/use-selection' export { type Selection, useSelection } from './hooks/use-selection'
export {
clearPlacementSurface,
getPlacementSurface,
type PlacementSurface,
publishPlacementSurface,
} from './lib/active-placement-surface'
export { export {
CEILING_ALIGNMENT_THRESHOLD_M, CEILING_ALIGNMENT_THRESHOLD_M,
type CeilingPlanSnapInput, type CeilingPlanSnapInput,
@@ -239,6 +246,13 @@ export {
} from './lib/ceiling-plan-snap' } from './lib/ceiling-plan-snap'
export { EDITOR_LAYER } from './lib/constants' export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels. // Helper libs used by the kind-owned roof / stair / elevator panels.
export {
CONTINUATION_PROFILES,
type ContinuationContext,
type ContinuationMode,
continuationContextOf,
nextContinuation,
} from './lib/continuation'
export { export {
resolveCurrentBuildingId, resolveCurrentBuildingId,
resolveElevatorNodeSupportY, resolveElevatorNodeSupportY,
@@ -267,6 +281,13 @@ export {
} from './lib/floorplan' } from './lib/floorplan'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { exportSceneToGlb } from './lib/glb-export' export { exportSceneToGlb } from './lib/glb-export'
export {
boundaryReshapeScope,
curveReshapeScope,
endpointReshapeScope,
holeEditScope,
movingNodeOf,
} from './lib/interaction/scope'
export { export {
buildResetSurfaceMaterialUpdates, buildResetSurfaceMaterialUpdates,
buildRoofSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch,
@@ -341,15 +362,33 @@ export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export type { export type {
FloorplanSelectionTool, FloorplanSelectionTool,
MovingFenceEndpoint,
MovingWallEndpoint,
SplitOrientation, SplitOrientation,
Tool, Tool,
ToolDefaults, ToolDefaults,
ViewMode, ViewMode,
WorkspaceMode, WorkspaceMode,
} from './store/use-editor' } from './store/use-editor'
export { default as useEditor } from './store/use-editor' export {
default as useEditor,
getActiveContinuationContext,
getContinuation,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
} from './store/use-editor'
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
export {
default as useInteractionScope,
getEditingHole,
getIsCurveReshape,
getMovingNode,
useActiveHandleDrag,
useEditingHole,
useEndpointReshape,
useIsCurveReshape,
useMovingNode,
useReshapingNode,
} from './store/use-interaction-scope'
export { export {
default as useOpeningGuides, default as useOpeningGuides,
type OpeningGuide3D, type OpeningGuide3D,
@@ -0,0 +1,35 @@
import { Vector3 } from 'three'
// The surface the active placement/move ghost is currently snapped to: a contact
// point (world space) and the surface's outward unit normal. Published each frame
// by the placement tools (the item coordinator + the drawn-kind tools) and read
// by the grid so its snap patch sits at the ghost's height AND orients to the
// surface — horizontal on a floor / shelf top, vertical in a wall plane.
//
// A plain module singleton (not a store): both writer and reader run inside
// `useFrame`, so reactivity would only add overhead. The vectors are reused, so
// readers must consume them within the same frame.
export type PlacementSurface = {
point: Vector3
normal: Vector3
}
const surface: PlacementSurface = {
point: new Vector3(),
normal: new Vector3(0, 1, 0),
}
let active = false
export function publishPlacementSurface(point: Vector3, normal: Vector3): void {
surface.point.copy(point)
surface.normal.copy(normal)
active = true
}
export function clearPlacementSurface(): void {
active = false
}
export function getPlacementSurface(): PlacementSurface | null {
return active ? surface : null
}
@@ -49,7 +49,10 @@ describe('resolveSelectModeHelpHints', () => {
keys: ['Cmd/Ctrl', 'Right click'], keys: ['Cmd/Ctrl', 'Right click'],
label: 'Drag left or right to rotate selected object', label: 'Drag left or right to rotate selected object',
}) })
expect(hints).toContainEqual({ // The Shift bypass hint is gated to the in-progress direct-move gesture
// (Cmd/Ctrl held); on an idle selection it must not appear (Shift there
// means multi-select, not bypass).
expect(hints).not.toContainEqual({
keys: ['Shift'], keys: ['Shift'],
label: 'Hold to bypass snaps and angle steps', label: 'Hold to bypass snaps and angle steps',
active: false, active: false,
@@ -1,9 +1,37 @@
export type ContextualShortcutHint = { export type ContextualShortcutHint = {
keys: string[] keys: string[]
label: string label: string
// Optional secondary line under the label for a terser qualifier
// (e.g. "disable 15° snap"). The HUD wraps both lines rather than truncating.
subtitle?: string
active?: boolean active?: boolean
} }
// `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'
// `activeHandleDrag.label` a plain resize / radial-resize arrow sets while
// dragging (when it carries no dimension `measureLabel`). It exists only so the
// interaction scope is non-idle during a resize, which keeps the idle
// select-mode hints off-screen — a resize is its own action, not a selection.
export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle'
// Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step
// (free rotation), the same toggle wall drafting exposes. `active` lights the
// pill while Shift is held.
export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualShortcutHint[] {
return [
{
keys: [SHIFT_KEY],
label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely',
active: shiftPressed,
},
]
}
export type SelectModeHelpContext = { export type SelectModeHelpContext = {
selectedCount: number selectedCount: number
hasMovableSelection: boolean hasMovableSelection: boolean
@@ -101,11 +129,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({ hints.push({
keys: [SHIFT_KEY], keys: [SHIFT_KEY],
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps', label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
active: shiftPressed, active: shiftPressed,
}) })
}
if (!commandPressed) { if (!commandPressed) {
hints.push({ hints.push({
+49
View File
@@ -0,0 +1,49 @@
export type ContinuationContext = 'wall' | 'fence' | 'point'
export type ContinuationMode = string
export const CONTINUATION_PROFILES: Record<
ContinuationContext,
{
options: ContinuationMode[]
default: ContinuationMode
labels: Record<string, string>
icons: Record<string, string>
}
> = {
wall: {
options: ['room', 'single'],
default: 'room',
labels: { room: 'Room (auto-close)', single: 'Single wall' },
icons: { room: 'lucide:square', single: 'lucide:minus' },
},
fence: {
options: ['continuous', 'single'],
default: 'continuous',
labels: { continuous: 'Continuous', single: 'Single fence' },
icons: { continuous: 'lucide:waypoints', single: 'lucide:minus' },
},
point: {
options: ['once', 'repeat'],
default: 'once',
labels: { once: 'Place once', repeat: 'Place multiple' },
icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' },
},
}
const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column'])
export function nextContinuation(
context: ContinuationContext,
current: ContinuationMode,
): ContinuationMode {
const profile = CONTINUATION_PROFILES[context]
const index = profile.options.indexOf(current)
if (index === -1) return profile.default
return profile.options[(index + 1) % profile.options.length] ?? profile.default
}
export function continuationContextOf(kind: string): ContinuationContext | null {
if (kind === 'wall') return 'wall'
if (kind === 'fence') return 'fence'
return POINT_KINDS.has(kind) ? 'point' : null
}
+13 -35
View File
@@ -1,33 +1,16 @@
import type { AnyNode, EditorApi, FenceNode, WallNode } from '@pascal-app/core' import type { AnyNode, EditorApi } from '@pascal-app/core'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
import useInteractionScope from '../store/use-interaction-scope'
type EditorState = ReturnType<typeof useEditor.getState> import { endpointReshapeScope } from './interaction/scope'
type EndpointEngager = (node: AnyNode, endpoint: 'start' | 'end', editor: EditorState) => void
/** /**
* Per-kind endpoint-move engagement. Kinds whose 2D endpoint drag * Concrete {@link EditorApi} backed by `useEditor` + the interaction scope.
* needs its own store field (wall ↔ `movingWallEndpoint`, fence ↔ * Descriptors call into editor state through this interface; the editor owns
* `movingFenceEndpoint`) register their bridge here. The dispatcher * the actual store wiring so core stays decoupled.
* is a table lookup rather than an `if (type === 'wall')` chain so
* adding a new endpoint-draggable kind is a one-line entry instead
* of a new branch. Each entry casts the generic `AnyNode` to its
* concrete kind — the lookup key already guarantees the type.
*/
const endpointEngagers: Record<string, EndpointEngager> = {
wall: (node, endpoint, editor) =>
editor.setMovingWallEndpoint({ wall: node as WallNode, endpoint }),
fence: (node, endpoint, editor) =>
editor.setMovingFenceEndpoint({ fence: node as FenceNode, endpoint }),
}
/**
* Concrete {@link EditorApi} backed by `useEditor`. Descriptors call into
* editor state through this interface; the editor owns the actual setter
* names so core stays decoupled.
* *
* `engageMove` clears any in-progress endpoint drag or curve gesture so * `engageMove` no longer clears any in-progress endpoint drag or curve gesture:
* the move tool takes over cleanly — mirrors the legacy bookkeeping that * `setMovingNode` begins the `moving` scope, and the scope is single-owner, so
* lived inside `WallMoveArrowHandle.activateWallMove` / `FenceMoveArrowHandle`. * it atomically replaces any prior reshape — there is no separate flag to reset.
*/ */
export function createEditorApi(): EditorApi { export function createEditorApi(): EditorApi {
return { return {
@@ -39,10 +22,6 @@ export function createEditorApi(): EditorApi {
// cast lets registry-driven move kinds through without forcing a // cast lets registry-driven move kinds through without forcing a
// schema-level type widening. // schema-level type widening.
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0]) editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
editor.setMovingWallEndpoint(null)
editor.setMovingFenceEndpoint(null)
editor.setCurvingWall(null)
editor.setCurvingFence(null)
}, },
engageMoveDrag(node: AnyNode) { engageMoveDrag(node: AnyNode) {
const editor = useEditor.getState() const editor = useEditor.getState()
@@ -50,13 +29,12 @@ export function createEditorApi(): EditorApi {
// it at setup and wires its commit-on-release listener. // it at setup and wires its commit-on-release listener.
editor.setPlacementDragMode(true) editor.setPlacementDragMode(true)
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0]) editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
editor.setMovingWallEndpoint(null)
editor.setMovingFenceEndpoint(null)
editor.setCurvingWall(null)
editor.setCurvingFence(null)
}, },
engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') { engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
endpointEngagers[node.type]?.(node, endpoint, useEditor.getState()) // Endpoint reshape is kind-agnostic: the scope carries the node id + which
// endpoint, and consumers recover the node from the scene. Adding a new
// endpoint-draggable kind needs no entry here.
useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint))
}, },
} }
} }
@@ -11,6 +11,46 @@ export function rotatePlanVector(x: number, y: number, rotation: number): [numbe
return [x * cos + y * sin, -x * sin + y * cos] return [x * cos + y * sin, -x * sin + y * cos]
} }
// Converts a world X/Z point into the floor-plan-local (building-local)
// frame used by the SVG scene `<g>` and every stored node position. The
// inverse of `floorplanLocalToWorldPoint`. Shared so the floor-plan panel
// and the 2D move overlay resolve the same frame — feeding a world-space
// `original` into a local-space cursor solver lands the drop off by the
// building's world X/Z (worse for an off-origin building).
export function worldToFloorplanLocalPoint(
worldX: number,
worldZ: number,
buildingPosition: readonly [number, number, number],
buildingRotationY: number,
): Point2D {
const dx = worldX - buildingPosition[0]
const dz = worldZ - buildingPosition[2]
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
return {
x: dx * cos - dz * sin,
y: dx * sin + dz * cos,
}
}
// Inverse of `worldToFloorplanLocalPoint`: floor-plan-local X/Y → world X/Z.
export function floorplanLocalToWorldPoint(
point: Point2D | [number, number],
buildingPosition: readonly [number, number, number],
buildingRotationY: number,
): { x: number; z: number } {
const localX = Array.isArray(point) ? point[0] : point.x
const localY = Array.isArray(point) ? point[1] : point.y
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
return {
x: buildingPosition[0] + localX * cos + localY * sin,
z: buildingPosition[2] - localX * sin + localY * cos,
}
}
export function getRotatedRectanglePolygon( export function getRotatedRectanglePolygon(
center: Point2D, center: Point2D,
width: number, width: number,
@@ -8,6 +8,7 @@ export {
export { export {
clampPlanValue, clampPlanValue,
doesPolygonIntersectSelectionBounds, doesPolygonIntersectSelectionBounds,
floorplanLocalToWorldPoint,
getDistanceToWallSegment, getDistanceToWallSegment,
getFloorplanSelectionBounds, getFloorplanSelectionBounds,
getPlanPointDistance, getPlanPointDistance,
@@ -20,6 +21,7 @@ export {
movePlanPointTowards, movePlanPointTowards,
pointMatchesWallPlanPoint, pointMatchesWallPlanPoint,
rotatePlanVector, rotatePlanVector,
worldToFloorplanLocalPoint,
} from './geometry' } from './geometry'
export { export {
buildFloorplanItemEntry, buildFloorplanItemEntry,
@@ -0,0 +1,133 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '@pascal-app/core'
import {
type AttachClass,
attachClassOf,
type HotSetCandidate,
isCandidateInHotSet,
isPickableForAttach,
} from './hot-set'
const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
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,
node: mockNode('i1', 'item'),
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,
node: mockNode('w1', 'window'),
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,51 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '@pascal-app/core'
import { resolveOverlayPolicy } from './overlay-policy'
import type { ActiveInteractionScope } from './scope'
const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
const ACTIVE_SCOPES: ActiveInteractionScope[] = [
{
kind: 'placing',
node: mockNode('i1', 'item'),
nodeId: 'i1',
nodeType: 'item',
view: '3d',
pressDrag: false,
},
{ kind: 'moving', node: mockNode('i1', 'item'), 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,176 @@
// 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.
import type { AnyNode } from '@pascal-app/core'
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'
// The node being placed, carried inline: a fresh-placement / duplicate
// draft is not in the scene yet, so it cannot be recovered by id. Set once
// at `begin` and never mutated, so it is a stable reference for the gesture.
node: AnyNode
nodeId: string
nodeType: string
view: InteractionView
pressDrag: boolean
}
// Moving an existing node.
| { kind: 'moving'; node: AnyNode; 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). `holeIndex` is set
// only for `reshape: 'hole'`; `endpoint` only for `reshape: 'endpoint'`.
| {
kind: 'reshaping'
nodeId: string
reshape: ReshapeKind
holeIndex?: number
endpoint?: 'start' | 'end'
}
// 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
}
}
// The node a placing/moving scope is acting on, carried inline (see the
// `placing` variant comment). Null for every other scope. Replaces the legacy
// `useEditor.movingNode` flag: the node lives inside the discriminated union, so
// it cannot survive past the interaction's `end()`.
export function movingNodeOf(scope: InteractionScope): AnyNode | null {
return scope.kind === 'placing' || scope.kind === 'moving' ? scope.node : 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'
}
// Derived views of the scope that mirror the legacy `useEditor` flags they
// replaced. Each returns null unless that exact interaction is active, so a
// stale payload is unrepresentable: the value is a pure function of the single
// authoritative scope, not an independent flag that can drift out of sync.
// The legacy `activeHandleDrag` flag. `label` keeps the legacy field name so
// downstream `=== ROTATE_HANDLE_DRAG_LABEL` / `=== 'height'` checks are unchanged.
export function handleDragInfo(scope: InteractionScope): { nodeId: string; label: string } | null {
return scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
}
// The legacy `editingHole` flag (`SurfaceHoleTarget`).
export function editingHoleInfo(
scope: InteractionScope,
): { nodeId: string; holeIndex: number } | null {
return scope.kind === 'reshaping' && scope.reshape === 'hole' && scope.holeIndex !== undefined
? { nodeId: scope.nodeId, holeIndex: scope.holeIndex }
: null
}
// Build the scope payload for a hole reshape, so producers don't re-spell the
// discriminator at every call site.
export function holeEditScope(target: {
nodeId: string
holeIndex: number
}): ActiveInteractionScope {
return {
kind: 'reshaping',
nodeId: target.nodeId,
reshape: 'hole',
holeIndex: target.holeIndex,
}
}
// True while the selected node's geometry is being curved (legacy
// `curvingWall` / `curvingFence` — now one scope; the wall-vs-fence kind is
// recovered from the reshaped node's type, looked up from the scene by nodeId).
export function isCurveReshape(scope: InteractionScope): boolean {
return scope.kind === 'reshaping' && scope.reshape === 'curve'
}
// The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
// itself (consumers fetch the node from the scene by `nodeId`; it is stable for
// the duration of the drag).
export function endpointReshapeInfo(
scope: InteractionScope,
): { nodeId: string; endpoint: 'start' | 'end' } | null {
return scope.kind === 'reshaping' && scope.reshape === 'endpoint' && scope.endpoint !== undefined
? { nodeId: scope.nodeId, endpoint: scope.endpoint }
: null
}
// The id of the node being reshaped (any reshape kind), for the scene lookup
// that recovers the full node payload a few consumers still need.
export function reshapingNodeId(scope: InteractionScope): string | null {
return scope.kind === 'reshaping' ? scope.nodeId : null
}
// Builders so producers don't re-spell the discriminator at every call site.
export function curveReshapeScope(nodeId: string): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'curve' }
}
export function endpointReshapeScope(
nodeId: string,
endpoint: 'start' | 'end',
): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
}
// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'boundary' }
}
+229
View File
@@ -0,0 +1,229 @@
import { describe, expect, it } from 'bun:test'
import type { AnyNode, ItemNode, SlabNode, Space } from '@pascal-app/core'
import {
availablePaintScopes,
cyclePaintScope,
type PaintHoverInfo,
type PaintScope,
paintScopeLabel,
resolvePaintScopeTargets,
} from './paint-scope'
describe('availablePaintScopes', () => {
it('every node offers single', () => {
expect(availablePaintScopes({ node: roof(), slotRoles: ['top'] })).toEqual(['single'])
})
it('more than one slot adds whole-object', () => {
expect(availablePaintScopes({ node: roof(), slotRoles: ['top', 'edge'] })).toEqual([
'single',
'object',
])
})
it('a single slot does not add whole-object', () => {
expect(availablePaintScopes({ node: roof(), slotRoles: ['top'] })).not.toContain('object')
})
it('an asset adds all-matching (items)', () => {
expect(availablePaintScopes({ node: item('a', 'sofa'), slotRoles: ['seat'] })).toContain(
'matching',
)
})
// `room` derives from the kind's registry `capabilities.paint.roomScope`, which
// isn't wired in this unit context; its resolver behaviour is covered below.
})
describe('cyclePaintScope', () => {
it('wraps within the given set', () => {
const set: PaintScope[] = ['single', 'object', 'matching']
expect(cyclePaintScope('single', set)).toBe('object')
expect(cyclePaintScope('object', set)).toBe('matching')
expect(cyclePaintScope('matching', set)).toBe('single')
})
it('a scope foreign to the set restarts at the first entry', () => {
expect(cyclePaintScope('matching', ['single', 'room'])).toBe('single')
})
it('an empty set stays single', () => {
expect(cyclePaintScope('single', [])).toBe('single')
})
})
describe('paintScopeLabel', () => {
const info = (over: Partial<PaintHoverInfo>): PaintHoverInfo => ({
scopes: ['single'],
slotLabel: 'Seat cushion',
nodeNoun: 'item',
...over,
})
it('single shows the hovered slot label', () => {
expect(paintScopeLabel('single', info({ slotLabel: 'Seat cushion' }))).toBe('Seat cushion')
})
it('single falls back when there is no slot label', () => {
expect(paintScopeLabel('single', info({ slotLabel: '' }))).toBe('This surface')
})
it('object reads "Whole <noun>"', () => {
expect(paintScopeLabel('object', info({ nodeNoun: 'shelf' }))).toBe('Whole shelf')
})
it('matching / room are kind-agnostic', () => {
expect(paintScopeLabel('matching', info({}))).toBe('All matching')
expect(paintScopeLabel('room', info({}))).toBe('Room')
})
})
// ── resolvePaintScopeTargets ────────────────────────────────────────────────
function item(id: string, assetId: string): ItemNode {
return { id, type: 'item', asset: { id: assetId } } as unknown as ItemNode
}
function slab(id: string, polygon: Array<[number, number]>): SlabNode {
return { id, type: 'slab', polygon } as unknown as SlabNode
}
function wall(id: string, start: [number, number], end: [number, number]): AnyNode {
return { id, type: 'wall', start, end } as unknown as AnyNode
}
function roof(): AnyNode {
return { id: 'r', type: 'roof' } as unknown as AnyNode
}
function asMap(nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
const noSlotRoles = () => [] as string[]
// `nodeId` is a branded id type; compare by plain `id:role` strings.
function keys(targets: Array<{ nodeId: string; role: string }>): string[] {
return targets.map((target) => `${target.nodeId}:${target.role}`)
}
function resolve(args: {
node: AnyNode
role?: string
scope: PaintScope
nodes: AnyNode[]
spaces?: Space[]
slotRolesOf?: (node: AnyNode) => string[]
}) {
return resolvePaintScopeTargets({
node: args.node,
role: args.role ?? 'surface',
scope: args.scope,
nodes: asMap(args.nodes),
spaces: Object.fromEntries((args.spaces ?? []).map((s) => [s.id, s])),
slotRolesOf: args.slotRolesOf ?? noSlotRoles,
})
}
describe('resolvePaintScopeTargets', () => {
it('single always returns just the clicked surface', () => {
const a = item('a', 'sofa')
expect(
keys(resolve({ node: a, role: 'seat', scope: 'single', nodes: [a, item('b', 'sofa')] })),
).toEqual(['a:seat'])
})
it('item matching fans the same slot across same-asset items only', () => {
const a = item('a', 'sofa')
const b = item('b', 'sofa')
const c = item('c', 'lamp')
const result = resolve({ node: a, role: 'seat', scope: 'matching', nodes: [a, b, c] })
expect(keys(result).sort()).toEqual(['a:seat', 'b:seat'])
})
it('item whole-item fans every enumerated slot of the clicked item', () => {
const a = item('a', 'sofa')
const result = resolve({
node: a,
role: 'seat',
scope: 'object',
nodes: [a],
slotRolesOf: () => ['seat', 'legs', 'cushion'],
})
expect(keys(result)).toEqual(['a:seat', 'a:legs', 'a:cushion'])
})
it('item whole-item falls back to the single slot when the subtree is unmounted', () => {
const a = item('a', 'sofa')
expect(keys(resolve({ node: a, role: 'seat', scope: 'object', nodes: [a] }))).toEqual([
'a:seat',
])
})
it('wall room fans the same side across the walls bounding the room polygon', () => {
// A 4×4 room: each wall's endpoints are exact polygon vertices.
const w1 = wall('w1', [0, 0], [4, 0])
const w2 = wall('w2', [4, 0], [4, 4])
const w3 = wall('w3', [4, 4], [0, 4])
const w4 = wall('w4', [0, 4], [0, 0])
const wOut = wall('wOut', [10, 10], [14, 10]) // not on the room boundary
const space: Space = {
id: 's1',
levelId: 'l1',
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
wallIds: [], // always empty in practice — membership is geometric
isExterior: false,
}
const result = resolve({
node: w1,
role: 'interior',
scope: 'room',
nodes: [w1, w2, w3, w4, wOut],
spaces: [space],
})
expect(keys(result).sort()).toEqual([
'w1:interior',
'w2:interior',
'w3:interior',
'w4:interior',
])
})
it('wall room with no enclosing space falls back to single', () => {
const w1 = wall('w1', [0, 0], [4, 0])
expect(
keys(resolve({ node: w1, role: 'interior', scope: 'room', nodes: [w1], spaces: [] })),
).toEqual(['w1:interior'])
})
it('slab room fans across slabs whose centroid sits in the same space', () => {
const inside = slab('inA', [
[1, 1],
[3, 1],
[3, 3],
[1, 3],
])
const alsoInside = slab('inB', [
[2, 2],
[2.5, 2],
[2.5, 2.5],
[2, 2.5],
])
const outside = slab('out', [
[20, 20],
[21, 20],
[21, 21],
[20, 21],
])
const space: Space = {
id: 's1',
levelId: 'l1',
polygon: [
[0, 0],
[10, 0],
[10, 10],
[0, 10],
],
wallIds: [],
isExterior: false,
}
const result = resolve({
node: inside,
role: 'surface',
scope: 'room',
nodes: [inside, alsoInside, outside],
spaces: [space],
})
expect(keys(result).sort()).toEqual(['inA:surface', 'inB:surface'])
})
})
+318
View File
@@ -0,0 +1,318 @@
import {
type AnyNode,
type AnyNodeId,
generateSceneMaterialId,
type ItemNode,
type MaterialSchema,
nodeRegistry,
pointInPolygon2D,
pointOnSegment,
type SceneMaterial,
type SceneMaterialId,
type SlabNode,
type Space,
slotLabelFromId,
toSceneMaterialRef,
useScene,
type WallNode,
} from '@pascal-app/core'
/**
* Painter application scope — how far one paint click spreads. The scope set is
* DERIVED from the hovered node, not a per-kind table: any slot-model node with
* more than one slot offers `object` (whole node); a node with an `asset` offers
* `matching` (every instance of that asset); a kind that declares
* `capabilities.paint.roomScope` offers `room`. One global mode (not per-tool),
* defaulting to the narrowest `'single'`; the active interaction's HUD shows +
* cycles it within the hovered node's set.
*/
export type PaintScope = 'single' | 'object' | 'matching' | 'room'
/** What the paint HUD needs to render + cycle the scope chip for a hover. */
export type PaintHoverInfo = {
/** The scopes available for the hovered node, in cycle order (always ≥ 1). */
scopes: PaintScope[]
/** Display name of the hovered slot — the label for the `'single'` scope. */
slotLabel: string
/** Kind noun for the `'object'` label (e.g. "Whole shelf"). */
nodeNoun: string
}
function nodeHasAsset(node: AnyNode): boolean {
return Boolean((node as { asset?: { id?: string } }).asset?.id)
}
function nodeOffersRoomScope(node: AnyNode): boolean {
return nodeRegistry.get(node.type)?.capabilities?.paint?.roomScope === true
}
/**
* The scopes a hovered node offers, derived from the node itself: every node
* paints `single`; > 1 slot adds `object`; an `asset` adds `matching`; a
* `roomScope`-declaring kind adds `room`. `slotRoles` is the node's full slot set
* (declared or mesh-derived), passed in by the caller.
*/
export function availablePaintScopes(args: { node: AnyNode; slotRoles: string[] }): PaintScope[] {
const scopes: PaintScope[] = ['single']
if (args.slotRoles.length > 1) scopes.push('object')
if (nodeHasAsset(args.node)) scopes.push('matching')
if (nodeOffersRoomScope(args.node)) scopes.push('room')
return scopes
}
export function cyclePaintScope(scope: PaintScope, scopes: PaintScope[]): PaintScope {
const list = scopes.length > 0 ? scopes : (['single'] as PaintScope[])
const index = list.indexOf(scope)
return list[(index + 1) % list.length] ?? 'single'
}
export function paintScopeLabel(scope: PaintScope, info: PaintHoverInfo): string {
switch (scope) {
case 'object':
return `Whole ${info.nodeNoun}`
case 'matching':
return 'All matching'
case 'room':
return 'Room'
default:
return info.slotLabel || 'This surface'
}
}
/**
* All paintable slot roles of a node. Prefers the kind's declared
* `capabilities.slots` (node-authored, stable); falls back to the runtime mesh
* tags via the injected `meshSlotRoles` for kinds whose slots come from a GLB
* (items) rather than a declaration.
*/
export function nodeSlotRoles(node: AnyNode, meshSlotRoles: (node: AnyNode) => string[]): string[] {
const declared = nodeRegistry.get(node.type)?.capabilities?.slots?.(node)
if (declared && declared.length > 0) return declared.map((slot) => slot.slotId)
return meshSlotRoles(node)
}
/** Display label for the hovered slot — declared label wins, else derived from the id. */
export function slotDisplayLabel(node: AnyNode, role: string): string {
const declared = nodeRegistry
.get(node.type)
?.capabilities?.slots?.(node)
?.find((slot) => slot.slotId === role)
return declared?.label ?? slotLabelFromId(role)
}
// ── Fan-out resolution ──────────────────────────────────────────────────────
type SlotsNode = AnyNode & { slots?: Record<string, string> }
// Room polygons are built from wall *centerline* endpoints (see
// `extractRoomPolygons`), so a wall's `start`/`end` are exact polygon vertices —
// a small tolerance only absorbs float round-trips. `Space.wallIds` is always
// empty, so room membership is resolved geometrically here instead.
const WALL_ON_BOUNDARY_TOLERANCE = 0.05
function pointOnPolygonBoundary(
point: readonly [number, number],
polygon: ReadonlyArray<readonly [number, number]>,
tolerance: number,
): boolean {
for (let i = 0; i < polygon.length; i += 1) {
const a = polygon[i]
const b = polygon[(i + 1) % polygon.length]
if (
a &&
b &&
pointOnSegment(
point as [number, number],
a as [number, number],
b as [number, number],
tolerance,
)
) {
return true
}
}
return false
}
// A wall bounds a room when both its endpoints lie on the room polygon's
// boundary (a shared wall lies on two rooms' boundaries; a wall radiating out of
// a corner has only one endpoint on it and is correctly excluded).
function wallBoundsRoom(
wall: WallNode,
polygon: ReadonlyArray<readonly [number, number]>,
): boolean {
return (
pointOnPolygonBoundary(wall.start, polygon, WALL_ON_BOUNDARY_TOLERANCE) &&
pointOnPolygonBoundary(wall.end, polygon, WALL_ON_BOUNDARY_TOLERANCE)
)
}
function polygonCentroid(
points: ReadonlyArray<readonly [number, number]>,
): [number, number] | null {
if (points.length === 0) return null
let x = 0
let z = 0
for (const point of points) {
x += point[0]
z += point[1]
}
return [x / points.length, z / points.length]
}
/**
* Expand one paint hit (`node` + resolved `role`) into the full list of
* (node, role) targets the current `scope` should paint. Returns just the
* clicked surface for `'single'`, for any target whose scope set doesn't
* include the current scope, and whenever the spread resolves to a single
* element — so callers can keep the kind-specific single-node commit for that
* case and only batch when there's genuinely more than one target.
*
* `slotRolesOf` enumerates the node's full slot set (declared or mesh-derived,
* injected by the caller) for the whole-object scope.
*/
export function resolvePaintScopeTargets(args: {
node: AnyNode
role: string
scope: PaintScope
nodes: Record<string, AnyNode>
spaces: Record<string, Space>
slotRolesOf: (node: AnyNode) => string[]
}): Array<{ nodeId: AnyNodeId; role: string }> {
const { node, role, scope, nodes, spaces, slotRolesOf } = args
const single = [{ nodeId: node.id as AnyNodeId, role }]
if (scope === 'single') return single
// Whole object: paint every slot of the clicked node. Generic across any
// slot-model kind (item, shelf, door, …) — not item-specific.
if (scope === 'object') {
const roles = slotRolesOf(node)
const set = roles.length > 0 ? roles : [role]
return set.map((slotRole) => ({ nodeId: node.id as AnyNodeId, role: slotRole }))
}
// All matching: same slot across every instance of the node's asset (items).
if (scope === 'matching') {
const assetId = (node as ItemNode).asset?.id
if (!assetId) return single
return Object.values(nodes)
.filter((other) => other.type === 'item' && (other as ItemNode).asset?.id === assetId)
.map((other) => ({ nodeId: other.id as AnyNodeId, role }))
}
if (node.type === 'wall' && scope === 'room') {
const wall = node as WallNode
const space = Object.values(spaces).find((candidate) => wallBoundsRoom(wall, candidate.polygon))
if (!space) return single
return Object.values(nodes)
.filter((other) => other.type === 'wall' && wallBoundsRoom(other as WallNode, space.polygon))
.map((other) => ({ nodeId: other.id as AnyNodeId, role }))
}
if (node.type === 'slab' && scope === 'room') {
const centroid = polygonCentroid((node as SlabNode).polygon)
if (!centroid) return single
const space = Object.values(spaces).find((candidate) =>
pointInPolygon2D(centroid, candidate.polygon),
)
if (!space) return single
return Object.values(nodes)
.filter((other) => {
if (other.type !== 'slab') return false
const otherCentroid = polygonCentroid((other as SlabNode).polygon)
return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon)
})
.map((other) => ({ nodeId: other.id as AnyNodeId, role }))
}
return single
}
// ── Batched commit ──────────────────────────────────────────────────────────
// Structural equality for the one-off-colour dedup below. The slot model is
// uniform across item / wall / slab (`node.slots[role] = ref`), so the same
// matcher the per-kind commits use applies to the whole fan-out.
function materialsEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true
if (typeof a !== typeof b || a === null || b === null) return false
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
return a.every((value, index) => materialsEqual(value, b[index]))
}
if (typeof a === 'object') {
const aRecord = a as Record<string, unknown>
const bRecord = b as Record<string, unknown>
const aKeys = Object.keys(aRecord)
if (aKeys.length !== Object.keys(bRecord).length) return false
return aKeys.every(
(key) => Object.hasOwn(bRecord, key) && materialsEqual(aRecord[key], bRecord[key]),
)
}
return false
}
/**
* Apply one paint to many slot-model targets in a single undo step. Resolves
* the slot ref ONCE — a one-off colour creates a single shared scene material
* for the whole fan-out, not one per node — then writes every `node.slots[role]`
* (or deletes it, for the eraser) in one `useScene.setState`. Only ever called
* for item / wall / slab fan-outs, all of which use the unified slot model.
*/
export function commitPaintScopeFanout(
targets: ReadonlyArray<{ nodeId: AnyNodeId; role: string }>,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): void {
if (targets.length === 0) return
const state = useScene.getState()
let ref: string | undefined
let newSceneMaterial: SceneMaterial | null = null
if (material === undefined && materialPreset === undefined) {
ref = undefined // eraser → clear the slot back to its default
} else if (materialPreset) {
ref = materialPreset
} else if (material) {
const existing = Object.values(state.materials).find((scene) =>
materialsEqual(scene.material, material),
)
if (existing) {
ref = toSceneMaterialRef(existing.id)
} else {
const id = generateSceneMaterialId()
newSceneMaterial = {
id,
name: `Material ${Object.keys(state.materials).length + 1}`,
material,
}
ref = toSceneMaterialRef(id)
}
} else {
return
}
useScene.setState((current) => {
if (current.readOnly) return current
const nextNodes = { ...current.nodes }
let changed = false
for (const { nodeId, role } of targets) {
const node = nextNodes[nodeId] as SlotsNode | undefined
if (!node) continue
const nextSlots = { ...(node.slots ?? {}) }
if (ref) nextSlots[role] = ref
else delete nextSlots[role]
nextNodes[nodeId] = { ...node, slots: nextSlots } as AnyNode
changed = true
}
if (!changed) return current
return {
nodes: nextNodes,
materials: newSceneMaterial
? { ...current.materials, [newSceneMaterial.id as SceneMaterialId]: newSceneMaterial }
: current.materials,
}
})
for (const { nodeId } of targets) state.markDirty(nodeId)
}
@@ -40,4 +40,61 @@ describe('resolvePlanarCursorPosition', () => {
expect(moved.point).toEqual([11, 19]) expect(moved.point).toEqual([11, 19])
expect(moved.anchor).toEqual([4.1, 6.1]) expect(moved.anchor).toEqual([4.1, 6.1])
}) })
// Track B regression: "off-slab cursor, on-slab footprint stays at center".
// When the gizmo is grabbed off the footprint center (e.g. near a slab edge),
// the resolved center must track original + cursorDelta and be independent of
// the initial grab offset — so a footprint fully inside a slab cannot be
// pushed off the edge just because the cursor sample landed off-center.
test('relative mode cancels the off-center gizmo grab offset so the committed center is offset-independent', () => {
const original: [number, number] = [2, 2]
const firstSample: [number, number] = [2.3, 2.3]
const cursor: [number, number] = [3.1, 1.6]
const start = resolvePlanarCursorPosition({
cursor: firstSample,
original,
anchor: null,
mode: 'relative',
})
// First sample absorbs the off-center grab: the footprint stays put.
expect(start.point).toEqual(original)
expect(start.anchor).toEqual(firstSample)
const moved = resolvePlanarCursorPosition({
cursor,
original,
anchor: start.anchor,
mode: 'relative',
})
// Committed center = original + (cursor - firstSample), i.e. the gizmo
// offset is cancelled regardless of where on the footprint it was grabbed.
const expected: [number, number] = [
original[0] + (cursor[0] - firstSample[0]),
original[1] + (cursor[1] - firstSample[1]),
]
expect(moved.point[0]).toBeCloseTo(expected[0])
expect(moved.point[1]).toBeCloseTo(expected[1])
// The result must not depend on the absolute grab offset: grabbing the same
// footprint dead-center and moving by the same delta yields the same center.
const centerStart = resolvePlanarCursorPosition({
cursor: original,
original,
anchor: null,
mode: 'relative',
})
const delta: [number, number] = [cursor[0] - firstSample[0], cursor[1] - firstSample[1]]
const centerMoved = resolvePlanarCursorPosition({
cursor: [original[0] + delta[0], original[1] + delta[1]],
original,
anchor: centerStart.anchor,
mode: 'relative',
})
expect(centerMoved.point[0]).toBeCloseTo(moved.point[0])
expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
})
}) })
-2
View File
@@ -359,10 +359,8 @@ function resetEditorInteractionState() {
structureLayer: 'elements', structureLayer: 'elements',
catalogCategory: null, catalogCategory: null,
selectedItem: null, selectedItem: null,
movingNode: null,
selectedReferenceId: null, selectedReferenceId: null,
spaces: {}, spaces: {},
editingHole: null,
hoveredHole: null, hoveredHole: null,
isPreviewMode: false, isPreviewMode: false,
}) })
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'bun:test'
import {
cycleSnappingModeIn,
DEFAULT_SNAPPING_MODE,
defaultSnappingModeFor,
nextSnappingMode,
resolveSnapFlags,
SNAPPING_MODES,
snapContextOf,
snappingModesFor,
} from './snapping-mode'
describe('resolveSnapFlags', () => {
it('default mode is grid', () => {
expect(DEFAULT_SNAPPING_MODE).toBe('grid')
})
it("modes are exclusive: 'grid' snaps to the lattice only", () => {
expect(resolveSnapFlags('grid')).toEqual({ grid: true, magnetic: false, angles: false })
})
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)
})
})
describe('per-context snapping', () => {
it('items default to free (lines) with no angle lock', () => {
expect(defaultSnappingModeFor('item')).toBe('lines')
expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
expect(snappingModesFor('item')).not.toContain('angles')
})
it('walls default to grid and expose the angle lock; polygons do NOT', () => {
expect(defaultSnappingModeFor('wall')).toBe('grid')
expect(defaultSnappingModeFor('polygon')).toBe('grid')
expect(snappingModesFor('wall')).toContain('angles')
// Angle lock is wall/fence-only — slabs, curves and translates never get it.
expect(snappingModesFor('polygon')).not.toContain('angles')
expect(snappingModesFor('polygon')).toEqual(['grid', 'lines', 'off'])
})
it('cycles within the context set and clamps a foreign value', () => {
expect(cycleSnappingModeIn('item', 'lines')).toBe('grid')
expect(cycleSnappingModeIn('item', 'off')).toBe('lines')
// 'angles' isn't an item mode → restart at the first entry
expect(cycleSnappingModeIn('item', 'angles')).toBe('lines')
})
})
describe('snapContextOf (profile-driven, node-declared)', () => {
// Stands in for the registry's declared `def.snapProfile` (the only per-kind
// data) — the resolver itself has no kind switch.
const declared: Record<string, 'item' | 'structural'> = {
wall: 'structural',
fence: 'structural',
item: 'item',
slab: 'structural',
ceiling: 'structural',
roof: 'structural',
zone: 'structural',
}
const profileOf = (t: string) => declared[t]
const ctx = (
scope: { kind: string; nodeType?: string; reshape?: string; tool?: string },
mode = 'select',
tool: string | null = null,
) => snapContextOf({ scope, mode, tool, profileOf })
it('translating a whole structural node has no angle (polygon, not wall)', () => {
expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon')
expect(ctx({ kind: 'moving', nodeType: 'slab' })).toBe('polygon')
expect(ctx({ kind: 'placing', nodeType: 'item' }, 'build', 'item')).toBe('item')
})
it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => {
expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall')
expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon')
expect(ctx({ kind: 'reshaping', reshape: 'boundary' })).toBe('polygon')
expect(ctx({ kind: 'reshaping', reshape: 'hole' })).toBe('polygon')
})
it('drafting a structural kind (wall OR slab) is angle-bearing (wall)', () => {
expect(ctx({ kind: 'idle' }, 'build', 'wall')).toBe('wall')
expect(ctx({ kind: 'idle' }, 'build', 'slab')).toBe('wall')
expect(ctx({ kind: 'idle' }, 'build', 'item')).toBe('item')
expect(ctx({ kind: 'idle' }, 'select', null)).toBeNull()
})
it('an undeclared kind (no snapProfile) gets no snap context', () => {
expect(ctx({ kind: 'moving', nodeType: 'door' })).toBeNull()
expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull()
})
it('drafting a non-directional structural kind is angle-less (polygon, not wall)', () => {
// Roof / stair / elevator are placed as footprints, not directional draws →
// declared `snapDraftDirectional: false`, so their draft context drops the
// angle-lock mode. Directional structural kinds (no flag) stay `wall`.
const draftDirectionalOf = (t: string) => (t === 'roof' ? false : true)
const draftCtx = (tool: string) =>
snapContextOf({ scope: { kind: 'idle' }, mode: 'build', tool, profileOf, draftDirectionalOf })
expect(draftCtx('roof')).toBe('polygon')
expect(draftCtx('wall')).toBe('wall')
// Also via the explicit `drafting` scope path.
expect(
snapContextOf({
scope: { kind: 'drafting', tool: 'roof' },
mode: 'build',
tool: 'roof',
profileOf,
draftDirectionalOf,
}),
).toBe('polygon')
})
})
+165
View File
@@ -0,0 +1,165 @@
import type { SnapProfile } from '@pascal-app/core'
/**
* 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 mode enum onto the individual snap knobs. Modes are
* EXCLUSIVE — each does exactly what its chip label says, one guide at a time,
* so the HUD is honest:
*
* - `grid` → grid lattice only.
* - `lines` → magnetic only: alignment axes + wall corner-join (connectivity
* is part of the "lines" magnetic snap, not a separate always-on behaviour).
* - `angles` → angle lock only (15°/45° rays).
* - `off` → nothing snaps (raw cursor).
*/
export function resolveSnapFlags(mode: SnappingMode): SnapFlags {
switch (mode) {
case 'grid':
return { grid: true, magnetic: false, angles: false }
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
}
// ── Per-context snapping ──────────────────────────────────────────────────────
//
// Snapping is no longer one global value: each *activity* has its own mode set
// and default, because they want different behaviour (drawing a wall wants a
// grid + angle lock; nudging an item wants free movement that only catches on
// alignment lines). The mode is remembered per context and shown live, so it's
// never a silent surprise — it just matches what you're doing.
export type SnapContext = 'wall' | 'item' | 'polygon'
// The cyclable mode-set for a context (distinct from the node's `SnapProfile`).
type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
// `modes[0]` is the cycle's first entry; `default` is what a context starts at.
// The 'wall' set is the ONLY one with an angle lock — it applies solely when
// you're setting a segment's DIRECTION (wall/fence drafting + endpoint drag).
// Translating a whole wall, curving it, or drawing/moving a slab can't change an
// angle, so those use the no-angle 'polygon' set.
const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
// Wall / fence drafting + endpoint reshape: direction matters → angle lock.
wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' },
// Item placement / move: free by default (lines = magnetic alignment only, no
// grid lattice), grid opt-in, no angle lock (meaningless for a footprint).
item: { modes: ['lines', 'grid', 'off'], default: 'lines' },
// Structural / surface, no direction to set: slab / ceiling / roof draft+move,
// whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
// default, NO angle lock.
polygon: { modes: ['grid', 'lines', 'off'], default: 'grid' },
}
export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon']
export function snappingModesFor(context: SnapContext): SnappingMode[] {
return SNAP_PROFILES[context].modes
}
export function defaultSnappingModeFor(context: SnapContext): SnappingMode {
return SNAP_PROFILES[context].default
}
// Cycle within the context's own set (clamps a foreign value to the first entry).
export function cycleSnappingModeIn(context: SnapContext, mode: SnappingMode): SnappingMode {
const modes = SNAP_PROFILES[context].modes
const index = modes.indexOf(mode)
return modes[(index + 1) % modes.length] ?? modes[0] ?? DEFAULT_SNAPPING_MODE
}
// The kind's declared `snapProfile` (from the registry) → the active mode-set
// context. The only behaviour difference is the angle lock, which a `structural`
// kind gets while SETTING DIRECTION (drafting a run/polygon, dragging an endpoint
// or a polygon vertex) — never while translating or curving. A node with no
// declared profile has no snapping UI (chip) yet — returns null.
function contextForProfile(
profile: SnapProfile | undefined,
directionSetting: boolean,
): SnapContext | null {
if (profile === 'item') return 'item'
if (profile === 'structural') return directionSetting ? 'wall' : 'polygon'
return null
}
/**
* The active snapping context, derived from what the user is doing — fully
* node-declared: the kind's `snapProfile` (looked up via the injected
* `profileOf`) supplies the data, and this maps (profile × action) to the
* mode-set. No per-kind switch lives here. `profileOf` is injected so this stays
* pure + testable and `snapping-mode` need not import the registry.
*
* Prefers the authoritative interaction scope; falls back to the build tool
* because the `drafting` scope isn't wired yet (wall/slab draw runs idle).
* Returns null when nothing snappable is active → no chip, safe-default snap.
*/
export function snapContextOf(args: {
scope: { kind: string; nodeType?: string; reshape?: string; nodeId?: string; tool?: string }
mode: string
tool: string | null
profileOf: (typeOrTool: string) => SnapProfile | undefined
// Whether drafting a kind sets a direction (angle-lock meaningful). Injected
// like `profileOf` so `snapping-mode` need not import the registry; defaults
// to `true` (the structural draw default) when not supplied.
draftDirectionalOf?: (typeOrTool: string) => boolean
}): SnapContext | null {
const { scope, mode, tool, profileOf, draftDirectionalOf } = args
switch (scope.kind) {
case 'placing':
case 'moving':
// A whole-node translate never sets direction → no angle.
return scope.nodeType ? contextForProfile(profileOf(scope.nodeType), false) : null
case 'reshaping':
// Dragging a wall ENDPOINT sets the segment's direction → angle-bearing
// 'wall'. Curving, and polygon vertex/edge edits (boundary / hole), don't
// — they use the no-angle 'polygon' set (grid / lines / off).
return scope.reshape === 'endpoint' ? 'wall' : 'polygon'
case 'drafting':
return scope.tool
? contextForProfile(profileOf(scope.tool), draftDirectionalOf?.(scope.tool) ?? true)
: null
default:
return mode === 'build' && tool
? contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true)
: null
}
}
+6 -3
View File
@@ -19,7 +19,7 @@ import {
type WallSnapRadii, type WallSnapRadii,
} from '../components/tools/wall/wall-drafting' } from '../components/tools/wall/wall-drafting'
import useAlignmentGuides from '../store/use-alignment-guides' import useAlignmentGuides from '../store/use-alignment-guides'
import useEditor from '../store/use-editor' import { isMagneticSnapActive } from '../store/use-editor'
import useWallSnapIndicator from '../store/use-wall-snap-indicator' import useWallSnapIndicator from '../store/use-wall-snap-indicator'
const SURFACE_SNAP_MOVING_ID = '__surface_snap__' const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
@@ -181,7 +181,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
const nodes = input.nodes ?? useScene.getState().nodes const nodes = input.nodes ?? useScene.getState().nodes
const walls = getLevelWalls(nodes, input.levelId, input.walls) const walls = getLevelWalls(nodes, input.levelId, input.walls)
const fallbackPoint = input.fallbackPoint const fallbackPoint = input.fallbackPoint
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap const magnetic = input.magnetic ?? isMagneticSnapActive()
const wallSnap = snapWallDraftPointDetailed({ const wallSnap = snapWallDraftPointDetailed({
point: input.rawPoint, point: input.rawPoint,
@@ -209,8 +209,11 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
useWallSnapIndicator.getState().clear() useWallSnapIndicator.getState().clear()
// Alignment is the magnetic ("lines") guide. Modes are exclusive, so it runs
// only when magnetic snap is on — `grid`/`angles`/`off` keep the grid/raw
// `fallbackPoint` instead of being pulled onto an alignment axis.
const basePoint = fallbackPoint ?? wallSnap.point const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || input.altKey) { if (input.align === false || input.altKey || !magnetic) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
} }
+290 -100
View File
@@ -16,6 +16,7 @@ import {
type FenceNode, type FenceNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
nodeRegistry,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type RoofSurfaceMaterialRole, type RoofSurfaceMaterialRole,
@@ -33,6 +34,13 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import {
CONTINUATION_PROFILES,
type ContinuationContext,
type ContinuationMode,
continuationContextOf,
nextContinuation,
} from '../lib/continuation'
import { import {
type ActivePaintMaterial, type ActivePaintMaterial,
type PaintableMaterialTarget, type PaintableMaterialTarget,
@@ -40,6 +48,21 @@ import {
resolvePaintTargetFromSelection, resolvePaintTargetFromSelection,
type SingleSurfaceMaterialRole, type SingleSurfaceMaterialRole,
} from '../lib/material-paint' } from '../lib/material-paint'
import {
cyclePaintScope as cyclePaintScopeValue,
type PaintHoverInfo,
type PaintScope,
} from '../lib/paint-scope'
import {
cycleSnappingModeIn,
defaultSnappingModeFor,
resolveSnapFlags,
type SnapContext,
type SnappingMode,
snapContextOf,
snappingModesFor,
} from '../lib/snapping-mode'
import useInteractionScope from './use-interaction-scope'
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
@@ -160,16 +183,6 @@ export type Tool = SiteTool | StructureTool | FurnishTool
*/ */
export type ToolDefaults = Record<string, unknown> export type ToolDefaults = Record<string, unknown>
export type MovingWallEndpoint = {
wall: WallNode
endpoint: 'start' | 'end'
}
export type MovingFenceEndpoint = {
fence: FenceNode
endpoint: 'start' | 'end'
}
export type MaterialTargetRole = export type MaterialTargetRole =
| WallSurfaceSide | WallSurfaceSide
| StairSurfaceMaterialRole | StairSurfaceMaterialRole
@@ -219,25 +232,6 @@ type EditorState = {
setCatalogCategory: (category: CatalogCategory | null) => void setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: AssetInput | null selectedItem: AssetInput | null
setSelectedItem: (item: AssetInput) => void setSelectedItem: (item: AssetInput) => void
movingNode:
| ItemNode
| WindowNode
| DoorNode
| ElevatorNode
| CeilingNode
| ChimneyNode
| ColumnNode
| DormerNode
| SlabNode
| WallNode
| FenceNode
| RoofNode
| RoofSegmentNode
| SpawnNode
| StairNode
| StairSegmentNode
| BuildingNode
| null
/** /**
* True while a move was engaged by a press-drag gizmo (the on-canvas move * True while a move was engaged by a press-drag gizmo (the on-canvas move
* cross) rather than a click-to-place flow. The placement coordinator reads * cross) rather than a click-to-place flow. The placement coordinator reads
@@ -285,22 +279,6 @@ type EditorState = {
*/ */
movingNodeOrigin: '2d' | '3d' | null movingNodeOrigin: '2d' | '3d' | null
setMovingNodeOrigin: (origin: '2d' | '3d' | null) => void setMovingNodeOrigin: (origin: '2d' | '3d' | null) => void
movingWallEndpoint: MovingWallEndpoint | null
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
movingFenceEndpoint: MovingFenceEndpoint | null
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
/**
* Generic per-kind handle drag state. Set by a node's resize handle
* (height arrow, width arrow, rise / sweep / inner-radius for curved
* stairs, …) at drag-start and cleared on drag-end. `label`
* identifies which dimension the handle controls — measurement
* overlays read it to render the right caption; the camera controls
* use the truthy value to suppress one-finger pan-rotate. Replaces
* the previous per-kind `resizing*` fields so adding a new resize
* handle doesn't require a new store field.
*/
activeHandleDrag: { nodeId: AnyNodeId; label: string } | null
setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | null) => void
/** /**
* World axis the R/T keyboard rotation turns around, for kinds with * World axis the R/T keyboard rotation turns around, for kinds with
* full 3D orientation (duct fittings). Alt cycles it Y → X → Z; the * full 3D orientation (duct fittings). Alt cycles it Y → X → Z; the
@@ -309,23 +287,42 @@ type EditorState = {
*/ */
rotationAxis: 'x' | 'y' | 'z' rotationAxis: 'x' | 'y' | 'z'
cycleRotationAxis: () => 'x' | 'y' | 'z' cycleRotationAxis: () => 'x' | 'y' | 'z'
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
curvingFence: FenceNode | null
setCurvingFence: (fence: FenceNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
activePaintMaterial: ActivePaintMaterial | null activePaintMaterial: ActivePaintMaterial | null
setActivePaintMaterial: (material: ActivePaintMaterial | null) => void setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
activePaintTarget: PaintableMaterialTarget activePaintTarget: PaintableMaterialTarget
setActivePaintTarget: (target: PaintableMaterialTarget) => void setActivePaintTarget: (target: PaintableMaterialTarget) => void
// Live vertex count of an in-progress polygon draft (slab / ceiling), so the
// contextual HUD can gate hints on it (e.g. "Finish" only once ≥ 3 points).
// 0 when not drafting. Not persisted.
draftVertexCount: number
setDraftVertexCount: (count: number) => void
// Painter application scope — how far one paint click spreads (this surface /
// whole item / all matching / room). One global mode, target-aware in the HUD
// (see `lib/paint-scope.ts`), defaulting to the narrowest `'single'`. Not
// persisted: a "paint everything" scope should reset each session.
paintScope: PaintScope
setPaintScope: (scope: PaintScope) => void
// Cycle the scope within the hovered node's available set and return the new
// value. Bound to Shift while in paint mode.
cyclePaintScope: () => PaintScope
// When true, clicking a surface in paint mode clears it back to its // When true, clicking a surface in paint mode clears it back to its
// default material instead of applying `activePaintMaterial`. // default material instead of applying `activePaintMaterial`.
paintEraser: boolean paintEraser: boolean
setPaintEraser: (eraser: boolean) => void setPaintEraser: (eraser: boolean) => void
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null // What the cursor is over in paint mode: the scopes it offers + labels for the
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void // HUD chip. `null` when not over a paintable surface (drives the "hover a
// surface" hint). Set by the selection-manager paint hover; not persisted.
paintHover: PaintHoverInfo | null
setPaintHover: (info: PaintHoverInfo | null) => void
// Embedder capability: true when a host (e.g. community) can locate a selected
// node in its catalog browser. Gates the node action menu's "Find" button; the
// editor itself emits `selection:find-node` and lets the host fulfil it. Not
// persisted — it's a per-mount capability the host registers.
canFindNode: boolean
setCanFindNode: (canFind: boolean) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
guideUi: Record<string, GuideUiState> guideUi: Record<string, GuideUiState>
@@ -335,9 +332,6 @@ type EditorState = {
// Space detection for cutaway mode // Space detection for cutaway mode
spaces: Record<string, Space> spaces: Record<string, Space>
setSpaces: (spaces: Record<string, Space>) => void setSpaces: (spaces: Record<string, Space>) => void
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
editingHole: SurfaceHoleTarget | null
setEditingHole: (hole: SurfaceHoleTarget | null) => void
hoveredHole: SurfaceHoleTarget | null hoveredHole: SurfaceHoleTarget | null
setHoveredHole: (hole: SurfaceHoleTarget | null) => void setHoveredHole: (hole: SurfaceHoleTarget | null) => void
// Preview mode (viewer-like experience inside the editor) // Preview mode (viewer-like experience inside the editor)
@@ -374,11 +368,28 @@ type EditorState = {
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep gridSnapStep: GridSnapStep
setGridSnapStep: (step: GridSnapStep) => void setGridSnapStep: (step: GridSnapStep) => void
// Cycles the grid step through GRID_SNAP_STEPS (0.5 → 0.25 → 0.1 → 0.05 →
// 0.5) and returns the new value. Bound to the measurement-step shortcut.
cycleGridSnapStep: () => GridSnapStep
// Magnetic snapping while drafting — snaps wall endpoints onto existing // Magnetic snapping while drafting — snaps wall endpoints onto existing
// wall corners / wall bodies (the "magnetic" beacon). Independent of grid // wall corners / wall bodies (the "magnetic" beacon). Independent of grid
// snap. On by default; toggled from the Display menu. // snap. On by default; toggled from the Display menu.
magneticSnap: boolean magneticSnap: boolean
setMagneticSnap: (enabled: boolean) => void setMagneticSnap: (enabled: boolean) => void
// Per-context, user-cyclable snapping mode (see `lib/snapping-mode.ts`). Each
// activity (wall / item / polygon) keeps its own mode + default, because they
// want different snapping — drawing a wall wants grid + angle, nudging an item
// wants free movement that only catches alignment lines. Resolved to the live
// context via `getActiveSnappingMode()`; maps onto `gridSnapStep`/`magneticSnap`
// via `resolveSnapFlags`. Persisted per context.
snappingModeByContext: Record<SnapContext, SnappingMode>
setSnappingMode: (context: SnapContext, mode: SnappingMode) => void
// Cycle the *active* context's mode within its own set; returns the new value.
cycleSnappingMode: () => SnappingMode
continuationByContext: Record<ContinuationContext, ContinuationMode>
setContinuation: (context: ContinuationContext, mode: ContinuationMode) => void
cycleContinuation: (context: ContinuationContext) => ContinuationMode
getContinuation: (context: ContinuationContext) => ContinuationMode
showReferenceFloor: boolean showReferenceFloor: boolean
toggleReferenceFloor: () => void toggleReferenceFloor: () => void
setShowReferenceFloor: (show: boolean) => void setShowReferenceFloor: (show: boolean) => void
@@ -427,6 +438,8 @@ type PersistedEditorLayoutState = Pick<
| 'floorplanSelectionTool' | 'floorplanSelectionTool'
| 'gridSnapStep' | 'gridSnapStep'
| 'magneticSnap' | 'magneticSnap'
| 'snappingModeByContext'
| 'continuationByContext'
| 'showReferenceFloor' | 'showReferenceFloor'
| 'referenceFloorOffset' | 'referenceFloorOffset'
| 'referenceFloorOpacity' | 'referenceFloorOpacity'
@@ -450,6 +463,16 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
floorplanSelectionTool: 'click', floorplanSelectionTool: 'click',
gridSnapStep: 0.5, gridSnapStep: 0.5,
magneticSnap: true, magneticSnap: true,
snappingModeByContext: {
wall: defaultSnappingModeFor('wall'),
item: defaultSnappingModeFor('item'),
polygon: defaultSnappingModeFor('polygon'),
},
continuationByContext: {
wall: CONTINUATION_PROFILES.wall.default,
fence: CONTINUATION_PROFILES.fence.default,
point: CONTINUATION_PROFILES.point.default,
},
showReferenceFloor: false, showReferenceFloor: false,
referenceFloorOffset: 1, referenceFloorOffset: 1,
referenceFloorOpacity: 0.35, referenceFloorOpacity: 0.35,
@@ -552,8 +575,48 @@ export function normalizePersistedEditorUiState(
} }
} }
// Validate a persisted per-context mode against that context's allowed set
// (so e.g. a stale `angles` for items resets), falling back to its default.
function migrateSnappingMode(value: unknown, context: SnapContext): SnappingMode {
return snappingModesFor(context).includes(value as SnappingMode)
? (value as SnappingMode)
: defaultSnappingModeFor(context)
}
type LegacyContinuationState = {
continuationByContext?: Partial<Record<ContinuationContext, unknown>>
wallChainMode?: unknown
fenceChainMode?: unknown
}
function migrateContinuationMode(
value: unknown,
context: ContinuationContext,
): ContinuationMode | null {
const profile = CONTINUATION_PROFILES[context]
return profile.options.includes(value as ContinuationMode) ? (value as ContinuationMode) : null
}
function normalizeContinuationByContext(
state: LegacyContinuationState | null | undefined,
): Record<ContinuationContext, ContinuationMode> {
return {
wall:
migrateContinuationMode(state?.continuationByContext?.wall, 'wall') ??
migrateContinuationMode(state?.wallChainMode, 'wall') ??
CONTINUATION_PROFILES.wall.default,
fence:
migrateContinuationMode(state?.continuationByContext?.fence, 'fence') ??
migrateContinuationMode(state?.fenceChainMode, 'fence') ??
CONTINUATION_PROFILES.fence.default,
point:
migrateContinuationMode(state?.continuationByContext?.point, 'point') ??
CONTINUATION_PROFILES.point.default,
}
}
function normalizePersistedEditorLayoutState( function normalizePersistedEditorLayoutState(
state: Partial<PersistedEditorLayoutState> | null | undefined, state: (Partial<PersistedEditorLayoutState> & LegacyContinuationState) | null | undefined,
): PersistedEditorLayoutState { ): PersistedEditorLayoutState {
return { return {
activeSidebarPanel: activeSidebarPanel:
@@ -568,6 +631,12 @@ function normalizePersistedEditorLayoutState(
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
// Default on: only an explicit persisted `false` disables it. // Default on: only an explicit persisted `false` disables it.
magneticSnap: state?.magneticSnap !== false, magneticSnap: state?.magneticSnap !== false,
snappingModeByContext: {
wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'),
item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'),
},
continuationByContext: normalizeContinuationByContext(state),
showReferenceFloor: state?.showReferenceFloor === true, showReferenceFloor: state?.showReferenceFloor === true,
referenceFloorOffset: referenceFloorOffset:
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -760,6 +829,10 @@ const useEditor = create<EditorState>()(
else if (tool) { else if (tool) {
set({ tool: null }) set({ tool: null })
} }
const scope = useInteractionScope.getState()
if (mode === 'material-paint') scope.begin({ kind: 'painting' })
else scope.endIf((s) => s.kind === 'painting')
}, },
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool, tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
setTool: (tool) => set({ tool }), setTool: (tool) => set({ tool }),
@@ -795,44 +868,41 @@ const useEditor = create<EditorState>()(
setCatalogCategory: (category) => set({ catalogCategory: category }), setCatalogCategory: (category) => set({ catalogCategory: category }),
selectedItem: null, selectedItem: null,
setSelectedItem: (item) => set({ selectedItem: item }), setSelectedItem: (item) => set({ selectedItem: item }),
movingNode: null as
| ItemNode
| WindowNode
| DoorNode
| ElevatorNode
| CeilingNode
| ColumnNode
| SlabNode
| WallNode
| FenceNode
| RoofNode
| RoofSegmentNode
| SpawnNode
| StairNode
| StairSegmentNode
| BuildingNode
| null,
placementDragMode: false, placementDragMode: false,
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }), setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
setMovingNode: (node) => // The node being placed/moved now lives inside the interaction scope
set( // (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter
node === null // remains the single entry point: it drives the scope and still touches
? // Preserve `movingNodeOrigin` across the clear so the // `movingNodeOrigin` / `placementDragMode` so cross-store subscribers that
// non-owning side's effect cleanup — which fires after // watch this store (community placement) keep firing on move start/end.
// `setMovingNode(null)` propagates — can still read who setMovingNode: (node) => {
// finalised. The next non-null `setMovingNode` resets it. const scope = useInteractionScope.getState()
// Always clear the press-drag flag when a move ends. if (node === null) {
{ movingNode: null, placementDragMode: false } scope.endIf((s) => s.kind === 'placing' || s.kind === 'moving')
: { movingNode: node, movingNodeOrigin: 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.
set({ placementDragMode: false })
return
}
const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew)
if (isNew) {
scope.begin({
kind: 'placing',
node,
nodeId: node.id,
nodeType: node.type,
view: '3d',
pressDrag: get().placementDragMode,
})
} else {
scope.begin({ kind: 'moving', node, nodeId: node.id, nodeType: node.type, view: '3d' })
}
set({ movingNodeOrigin: null })
},
movingNodeOrigin: null as '2d' | '3d' | null, movingNodeOrigin: null as '2d' | '3d' | null,
setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }), setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
movingWallEndpoint: null,
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
movingFenceEndpoint: null,
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
activeHandleDrag: null,
setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
rotationAxis: 'y', rotationAxis: 'y',
cycleRotationAxis: () => { cycleRotationAxis: () => {
const order = ['y', 'x', 'z'] as const const order = ['y', 'x', 'z'] as const
@@ -840,10 +910,6 @@ const useEditor = create<EditorState>()(
set({ rotationAxis: next }) set({ rotationAxis: next })
return next return next
}, },
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
curvingFence: null,
setCurvingFence: (fence) => set({ curvingFence: fence }),
selectedMaterialTarget: null, selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
activePaintMaterial: null, activePaintMaterial: null,
@@ -856,6 +922,19 @@ const useEditor = create<EditorState>()(
set((state) => set((state) =>
state.activePaintTarget === target ? state : { activePaintTarget: target }, state.activePaintTarget === target ? state : { activePaintTarget: target },
), ),
draftVertexCount: 0,
setDraftVertexCount: (count) =>
set((state) => (state.draftVertexCount === count ? state : { draftVertexCount: count })),
paintScope: 'single',
setPaintScope: (scope) => set({ paintScope: scope }),
cyclePaintScope: () => {
// Cycle within the hovered node's available scopes (what the click will
// actually hit). With nothing paintable hovered there's only `single`.
const scopes = get().paintHover?.scopes ?? (['single'] as PaintScope[])
const next = cyclePaintScopeValue(get().paintScope, scopes)
set({ paintScope: next })
return next
},
paintEraser: false, paintEraser: false,
setPaintEraser: (eraser) => set({ paintEraser: eraser }), setPaintEraser: (eraser) => set({ paintEraser: eraser }),
primeMaterialPaintFromSelection: () => { primeMaterialPaintFromSelection: () => {
@@ -885,11 +964,10 @@ const useEditor = create<EditorState>()(
activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial, activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
} }
}, },
hoveredPaintTarget: null, paintHover: null,
setHoveredPaintTarget: (target) => setPaintHover: (info) => set({ paintHover: info }),
set((state) => canFindNode: false,
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target }, setCanFindNode: (canFind) => set({ canFindNode: canFind }),
),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
guideUi: {}, guideUi: {},
@@ -924,8 +1002,6 @@ const useEditor = create<EditorState>()(
}), }),
spaces: {}, spaces: {},
setSpaces: (spaces) => set({ spaces }), setSpaces: (spaces) => set({ spaces }),
editingHole: null,
setEditingHole: (hole) => set({ editingHole: hole }),
hoveredHole: null, hoveredHole: null,
setHoveredHole: (hole) => setHoveredHole: (hole) =>
set((state) => set((state) =>
@@ -1007,8 +1083,48 @@ const useEditor = create<EditorState>()(
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
setGridSnapStep: (step) => set({ gridSnapStep: step }), setGridSnapStep: (step) => set({ gridSnapStep: step }),
cycleGridSnapStep: () => {
const current = get().gridSnapStep
const index = GRID_SNAP_STEPS.indexOf(current)
const next = GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
set({ gridSnapStep: next })
return next
},
magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap, magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
setMagneticSnap: (enabled) => set({ magneticSnap: enabled }), setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
snappingModeByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingModeByContext,
setSnappingMode: (context, mode) =>
set((state) => ({
snappingModeByContext: { ...state.snappingModeByContext, [context]: mode },
})),
cycleSnappingMode: () => {
const context = getActiveSnapContext() ?? 'item'
const current = get().snappingModeByContext[context]
const next = cycleSnappingModeIn(context, current)
set((state) => ({
snappingModeByContext: { ...state.snappingModeByContext, [context]: next },
}))
return next
},
continuationByContext: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.continuationByContext,
setContinuation: (context, mode) => {
const next =
migrateContinuationMode(mode, context) ?? CONTINUATION_PROFILES[context].default
set((state) => ({
continuationByContext: { ...state.continuationByContext, [context]: next },
}))
},
cycleContinuation: (context) => {
const next = nextContinuation(context, get().getContinuation(context))
set((state) => ({
continuationByContext: { ...state.continuationByContext, [context]: next },
}))
return next
},
getContinuation: (context) => {
const current = get().continuationByContext[context]
return migrateContinuationMode(current, context) ?? CONTINUATION_PROFILES[context].default
},
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
toggleReferenceFloor: () => toggleReferenceFloor: () =>
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })), set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -1101,6 +1217,8 @@ const useEditor = create<EditorState>()(
floorplanSelectionTool: state.floorplanSelectionTool, floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep, gridSnapStep: state.gridSnapStep,
magneticSnap: state.magneticSnap, magneticSnap: state.magneticSnap,
snappingModeByContext: state.snappingModeByContext,
continuationByContext: state.continuationByContext,
showReferenceFloor: state.showReferenceFloor, showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset, referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity, referenceFloorOpacity: state.referenceFloorOpacity,
@@ -1109,4 +1227,76 @@ const useEditor = create<EditorState>()(
), ),
) )
/**
* Effective magnetic-snap state: the legacy `magneticSnap` flag AND the active
* context's snapping mode. With exclusive modes, magnetic (alignment axes + wall
* corner-join) is on only in `'lines'`. 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(getActiveSnappingMode()).magnetic
}
/**
* Effective angle-lock state: the active context's snapping mode. With exclusive
* modes the 15°/45° lock is on only in `'angles'`. Read from the smallest
* angle-lock choke points (wall / fence draft call sites).
*/
export function isAngleSnapActive(): boolean {
return resolveSnapFlags(getActiveSnappingMode()).angles
}
/**
* Effective grid-lattice state: the active context's snapping mode. With
* exclusive modes the grid quantize is on only in `'grid'`.
*/
export function isGridSnapActive(): boolean {
return resolveSnapFlags(getActiveSnappingMode()).grid
}
/**
* The snapping context for what the user is currently doing (wall / item /
* polygon), or null when nothing snappable is active. Derived from the
* authoritative interaction scope, falling back to the armed build tool (the
* `drafting` scope isn't wired). The single source every snap reader + the HUD
* resolve their mode through.
*/
export function getActiveSnapContext(): SnapContext | null {
const editor = useEditor.getState()
return snapContextOf({
scope: useInteractionScope.getState().scope,
mode: editor.mode,
tool: editor.tool,
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
})
}
export function getActiveContinuationContext(): ContinuationContext | null {
const scope = useInteractionScope.getState().scope
if (scope.kind === 'drafting') return continuationContextOf(scope.tool)
if (scope.kind === 'placing') return continuationContextOf(scope.nodeType)
if (scope.kind !== 'idle') return null
const editor = useEditor.getState()
if (editor.mode !== 'build' || !editor.tool) return null
return continuationContextOf(editor.tool)
}
export function getContinuation(context: ContinuationContext): ContinuationMode {
return useEditor.getState().getContinuation(context)
}
/**
* The effective snapping mode for the active context. Falls back to `item`'s
* default (free) when no snappable context is active, so a stray reader never
* grid-quantizes outside an interaction.
*/
export function getActiveSnappingMode(): SnappingMode {
const context = getActiveSnapContext()
if (!context) return defaultSnappingModeFor('item')
return useEditor.getState().snappingModeByContext[context]
}
export default useEditor export default useEditor
@@ -0,0 +1,44 @@
// Ephemeral store for the forward-facing floor triangle shown while placing or
// moving a node. A single editor-side overlay (`<FacingPoseIndicator>`)
// subscribes and renders the triangle; every placement/move path publishes its
// ghost pose here instead of drawing its own triangle. This is deliberately the
// one renderer for the facing indicator: rendering it from inside a tool's own
// cursor ghost (especially tools living in `@pascal-app/nodes`) left it
// invisible, while the editor-side overlay renders reliably. Producers clear on
// commit, cancel, and unmount.
//
// Poses are in the same building-local frame the tools already work in (the
// overlay is mounted inside ToolManager's building-local group).
import { create } from 'zustand'
export type FacingPose = {
/** Ghost origin in building-local space. */
position: [number, number, number]
/** Ghost yaw (radians). The triangle inherits this so it points where the
* node faces. */
rotationY: number
/** Footprint depth along the ghost's local +Z; the triangle sits just past
* `center[1] + depth / 2`. */
depth: number
/** Footprint centre offset `[x, z]` in the ghost's local frame. Defaults to
* the origin. Kinds whose forward edge isn't centred on the origin (e.g. a
* stair, whose run starts at the entry) shift the triangle via this. */
center?: [number, number]
/** Point along local -Z (the front is the -Z side) instead of +Z. */
reversed?: boolean
}
type FacingPoseState = {
pose: FacingPose | null
set(pose: FacingPose): void
clear(): void
}
const useFacingPose = create<FacingPoseState>((set) => ({
pose: null,
set: (pose) => set({ pose }),
clear: () => set({ pose: null }),
}))
export default useFacingPose
@@ -0,0 +1,101 @@
// Ephemeral store for the 2D floor-plan's in-flight DRAFT preview state — the
// hot, per-pointer-move values every build/edit tool republishes on `grid:move`
// (the snapped cursor point today; wall/fence/roof draft endpoints as later
// slices land here). It exists so those per-move updates DON'T live in
// `FloorplanPanel`'s own `useState`: the panel is a ~10k-line component whose
// render costs ~120-220ms, so a `setState` per move made every 2D draft tool
// feel laggy. Producers write via `getState().setX(...)` (no panel re-render);
// the small overlay leaves subscribe and re-render alone. Same pattern that
// keeps stair / column / elevator placement smooth (`useStairBuildPreview`,
// `usePlacementPreview`).
//
// Editor-only. Producers clear on tool-inactive, commit, and unmount.
import type { WallPlanPoint } from '@pascal-app/core'
import { create } from 'zustand'
/** Screen-space (SVG-local px) cursor point — drives the coordinate badge. */
type SvgPoint = { x: number; y: number }
type FloorplanDraftPreviewState = {
/** Snapped plan-XZ point under the cursor; drives the crosshair + the
* cursor-following polygon-draft preview. `null` when idle. */
cursorPoint: WallPlanPoint | null
/** Screen-space cursor point driving the coordinate-indicator badge. Set on
* every SVG `pointermove` while a build/select tool is active, so it's the
* single hottest 2D update — keeping it out of panel state is what stops the
* panel re-rendering per move. `null` when idle. */
cursorPosition: SvgPoint | null
/** Live END point of the open wall / fence / roof draft segment — the per-move
* endpoint that drives the 2D draft polygon + measurement. Each is `null`
* unless that tool's draft is open. The START points stay in panel state
* (set per click, low-frequency). */
wallDraftEnd: WallPlanPoint | null
fenceDraftEnd: WallPlanPoint | null
roofDraftEnd: WallPlanPoint | null
/** Set the snapped cursor point. No-ops (skips the store update, so
* subscribers don't re-render) when unchanged — `grid:move` fires far more
* often than the snapped cell actually changes. */
setCursorPoint(point: WallPlanPoint | null): void
/** Set the screen-space cursor point (deduped on x/y). */
setCursorPosition(point: SvgPoint | null): void
setWallDraftEnd(point: WallPlanPoint | null): void
setFenceDraftEnd(point: WallPlanPoint | null): void
setRoofDraftEnd(point: WallPlanPoint | null): void
reset(): void
}
function setPlanPointField(
field: 'wallDraftEnd' | 'fenceDraftEnd' | 'roofDraftEnd',
point: WallPlanPoint | null,
) {
return (
state: FloorplanDraftPreviewState,
): Partial<FloorplanDraftPreviewState> | typeof state => {
const prev = state[field]
if (!point && !prev) return state
if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
return { [field]: point }
}
}
export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set) => ({
cursorPoint: null,
cursorPosition: null,
wallDraftEnd: null,
fenceDraftEnd: null,
roofDraftEnd: null,
setCursorPoint: (point) =>
set((state) => {
const prev = state.cursorPoint
if (!point && !prev) return state
if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
return { cursorPoint: point }
}),
setCursorPosition: (point) =>
set((state) => {
const prev = state.cursorPosition
if (!point && !prev) return state
if (point && prev && prev.x === point.x && prev.y === point.y) return state
return { cursorPosition: point }
}),
setWallDraftEnd: (point) => set(setPlanPointField('wallDraftEnd', point)),
setFenceDraftEnd: (point) => set(setPlanPointField('fenceDraftEnd', point)),
setRoofDraftEnd: (point) => set(setPlanPointField('roofDraftEnd', point)),
reset: () =>
set((state) =>
state.cursorPoint === null &&
state.cursorPosition === null &&
state.wallDraftEnd === null &&
state.fenceDraftEnd === null &&
state.roofDraftEnd === null
? state
: {
cursorPoint: null,
cursorPosition: null,
wallDraftEnd: null,
fenceDraftEnd: null,
roofDraftEnd: null,
},
),
}))
@@ -0,0 +1,50 @@
// Ephemeral store for the 2D floor-plan marquee (box-select) drag — the hot,
// per-pointer-move rectangle the select tool republishes on every move. It
// lives here, not in `FloorplanPanel`'s `useState`, for the same reason as
// `useFloorplanDraftPreview`: the panel is a ~10k-line component whose render
// costs ~120-220ms, so a `setState` per move made dragging a selection box
// re-render the whole panel. Producers write via `getState()` (no panel
// re-render); the small marquee overlay leaf subscribes and re-renders alone.
//
// The whole drag struct lives here (not just the moving corner) so the
// pointer-move / -up handlers read it non-reactively via `getState()` and the
// panel never subscribes. Editor-only; reset on pointer-up / cancel /
// tool-inactive.
import type { WallPlanPoint } from '@pascal-app/core'
import { create } from 'zustand'
export type FloorplanMarqueeDrag = {
pointerId: number
startClientX: number
startClientY: number
startPlanPoint: WallPlanPoint
/** Moving corner under the cursor — the only field that changes per move. */
currentPlanPoint: WallPlanPoint
}
type FloorplanMarqueeState = {
drag: FloorplanMarqueeDrag | null
begin(drag: FloorplanMarqueeDrag): void
/** Advance the moving corner. No-ops (skips the store update, so the overlay
* doesn't re-render) when the snapped point is unchanged or no drag is open. */
setCurrent(point: WallPlanPoint): void
reset(): void
}
export const useFloorplanMarquee = create<FloorplanMarqueeState>((set) => ({
drag: null,
begin: (drag) => set({ drag }),
setCurrent: (point) =>
set((state) => {
const prev = state.drag
if (!prev) return state
if (prev.currentPlanPoint[0] === point[0] && prev.currentPlanPoint[1] === point[1]) {
return state
}
return { drag: { ...prev, currentPlanPoint: point } }
}),
reset: () => set((state) => (state.drag === null ? state : { drag: null })),
}))
export default useFloorplanMarquee
@@ -0,0 +1,186 @@
import { afterEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '@pascal-app/core'
import {
type ActiveInteractionScope,
editingHoleInfo,
handleDragInfo,
isActive,
isIdle,
scopeNodeId,
selectionEnabled,
} from '../lib/interaction/scope'
import useInteractionScope from './use-interaction-scope'
// A placing/moving scope carries the node inline. Tests only assert on id/type,
// so a structural stand-in is enough.
const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode
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',
node: mockNode('item_1', 'item'),
nodeId: 'item_1',
nodeType: 'item',
view: '3d',
})
expect(useInteractionScope.getState().scope).toEqual({
kind: 'moving',
node: mockNode('item_1', 'item'),
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',
node: mockNode('i1', 'item'),
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',
node: mockNode('x', 'item'),
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',
node: mockNode('i1', 'item'),
nodeId: 'i1',
nodeType: 'item',
view: '3d',
})
s.update({
kind: 'placing',
node: mockNode('i1', 'item'),
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')
})
})
describe('derived flag views are leak-free (no parallel flags)', () => {
const scope = () => useInteractionScope.getState().scope
test('handleDragInfo mirrors handle-drag and clears on end', () => {
const s = useInteractionScope.getState()
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
expect(handleDragInfo(scope())).toEqual({ nodeId: 'wall_1', label: 'height' })
s.end()
// After end the derived view is null — a stale activeHandleDrag is
// unrepresentable because it is a pure function of the single scope.
expect(handleDragInfo(scope())).toBeNull()
})
test('editingHoleInfo mirrors a hole reshape and clears on end', () => {
const s = useInteractionScope.getState()
s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 2 })
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 2 })
s.end()
expect(editingHoleInfo(scope())).toBeNull()
})
test('a non-hole reshape never reads as an editing hole', () => {
const s = useInteractionScope.getState()
s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve' })
expect(editingHoleInfo(scope())).toBeNull()
})
test('switching interactions never leaks the prior derived view', () => {
const s = useInteractionScope.getState()
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
// Single-owner replacement: the handle-drag view must vanish the instant a
// different interaction begins — the two cannot be simultaneously active.
s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 0 })
expect(handleDragInfo(scope())).toBeNull()
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 0 })
})
test('every active scope kind leaves at most the views it owns', () => {
const s = useInteractionScope.getState()
const kinds: ActiveInteractionScope[] = [
{
kind: 'placing',
node: mockNode('i', 'item'),
nodeId: 'i',
nodeType: 'item',
view: '3d',
pressDrag: false,
},
{ kind: 'moving', node: mockNode('i', 'item'), nodeId: 'i', nodeType: 'item', view: '3d' },
{ kind: 'drafting', tool: 'wall' },
{ kind: 'box-select' },
{ kind: 'painting' },
]
for (const k of kinds) {
s.begin(k)
// None of these own a handle-drag or hole view.
expect(handleDragInfo(scope())).toBeNull()
expect(editingHoleInfo(scope())).toBeNull()
}
s.end()
})
})
@@ -0,0 +1,124 @@
'use client'
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
import { useRef } from 'react'
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
import {
type ActiveInteractionScope,
editingHoleInfo,
endpointReshapeInfo,
handleDragInfo,
IDLE_SCOPE,
type InteractionScope,
isCurveReshape,
movingNodeOf,
reshapingNodeId,
} 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 })
},
}))
// Derived, reference-stable views of the active scope, replacing the legacy
// `useEditor.activeHandleDrag` / `useEditor.editingHole` flags. `useShallow`
// keeps the result reference-stable across unrelated scope changes, so hot-path
// subscribers (camera controls, floating menu) don't re-render on every update.
export const useActiveHandleDrag = (): { nodeId: string; label: string } | null =>
useInteractionScope(useShallow((s) => handleDragInfo(s.scope)))
export const useEditingHole = (): { nodeId: string; holeIndex: number } | null =>
useInteractionScope(useShallow((s) => editingHoleInfo(s.scope)))
// Imperative (non-React) reads for event handlers / effects.
export const getEditingHole = (): { nodeId: string; holeIndex: number } | null =>
editingHoleInfo(useInteractionScope.getState().scope)
export const getIsCurveReshape = (): boolean => isCurveReshape(useInteractionScope.getState().scope)
// Replaces the legacy `curvingWall` / `curvingFence` existence flags. The
// wall-vs-fence distinction (both now map to one `reshaping/'curve'` scope) is
// recovered by reading the reshaped node's type from `useReshapingNode`.
export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCurveReshape(s.scope))
// Replaces the legacy `movingWallEndpoint` / `movingFenceEndpoint` payloads,
// minus the node (fetch it from `useReshapingNode`).
export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null =>
useInteractionScope(useShallow((s) => endpointReshapeInfo(s.scope)))
// The node currently being reshaped (curve / endpoint / hole), looked up live
// from the scene by the scope's `nodeId`. During a reshape the scene node holds
// the same data the legacy `curvingWall` / `movingWallEndpoint.wall` carried, so
// consumers that need the full node (affordance-tool mounts, wall-vs-fence type
// checks) read it here instead of from a parallel flag.
export const useReshapingNode = (): AnyNode | null => {
const nodeId = useInteractionScope((s) => reshapingNodeId(s.scope))
// Snapshot the node ONCE when the reshape begins (keyed on nodeId), like the
// legacy `curvingWall` / `movingWallEndpoint.wall` flags did. The affordance
// tools write the node live during the drag; subscribing to the live scene
// node would feed those writes straight back into the tool — the curve resets
// on pointer-stop, the endpoint drag loops and freezes. nodeId is stable for
// the whole gesture, so a ref snapshot stays frozen until the next reshape.
const snapshot = useRef<{ id: string | null; node: AnyNode | null }>({ id: null, node: null })
if (snapshot.current.id !== nodeId) {
snapshot.current = {
id: nodeId,
node: nodeId ? (useScene.getState().nodes[nodeId as AnyNodeId] ?? null) : null,
}
}
return snapshot.current.node
}
// The node currently being placed or moved. Replaces the legacy
// `useEditor.movingNode` flag. Unlike `useReshapingNode`, no `useRef` snapshot is
// needed: the node is carried inline in the scope and set once at `begin`, so it
// is already a stable reference for the whole gesture (nothing calls `begin` mid
// drag). Returns null whenever no placing/moving interaction is active.
export const useMovingNode = (): AnyNode | null => useInteractionScope((s) => movingNodeOf(s.scope))
// Imperative (non-React) read for event handlers / effects.
export const getMovingNode = (): AnyNode | null =>
movingNodeOf(useInteractionScope.getState().scope)
export default useInteractionScope
@@ -0,0 +1,43 @@
// Ephemeral store for the stair tool's 2D floor-plan build preview. The stair
// tool's snapped cursor point + rotation publish here on each `grid:move` / R-T
// rotate; the floor-plan stair preview layer subscribes and renders the ghost
// staircase. This mirrors how `usePlacementPreview` keeps column / elevator
// placement smooth: the preview lives OUTSIDE `FloorplanPanel`, so a per-move
// update re-renders only the tiny preview layer, not the (expensive) panel.
//
// Editor-only, same rationale as `usePlacementPreview`. Producers clear on
// tool-inactive, commit, and unmount.
import { create } from 'zustand'
type StairPreviewPoint = [number, number]
type StairBuildPreviewState = {
/** Snapped plan-XZ point the ghost staircase sits at; `null` when idle. */
point: StairPreviewPoint | null
/** Yaw (radians), cycled by R / T. */
rotation: number
/** Set the snapped point. No-ops (skips the store update, so subscribers
* don't re-render) when the point is unchanged — `grid:move` fires far more
* often than the snapped cell actually changes. */
setPoint(point: StairPreviewPoint | null): void
rotateBy(deltaRadians: number): void
reset(): void
}
export const useStairBuildPreview = create<StairBuildPreviewState>((set) => ({
point: null,
rotation: 0,
setPoint: (point) =>
set((state) => {
const prev = state.point
if (!point && !prev) return state
if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
return { point }
}),
rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })),
reset: () =>
set((state) =>
state.point === null && state.rotation === 0 ? state : { point: null, rotation: 0 },
),
}))
+13 -3
View File
@@ -2,11 +2,13 @@
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { import {
boundaryReshapeScope,
clearCeilingSnapFeedback, clearCeilingSnapFeedback,
PolygonEditor, PolygonEditor,
type PolygonEditorPlanPointSnapContext, type PolygonEditorPlanPointSnapContext,
resolveCeilingPlanPointSnap, resolveCeilingPlanPointSnap,
triggerSFX, triggerSFX,
useInteractionScope,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -95,13 +97,19 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
const handleDragStateChange = useCallback( const handleDragStateChange = useCallback(
(isDragging: boolean) => { (isDragging: boolean) => {
if (!isDragging) { // A vertex/edge drag is a `boundary` reshape — drive the snapping HUD
// (no-angle 'polygon' set) and keep the idle select hints off-screen.
const scope = useInteractionScope.getState()
if (isDragging) {
scope.begin(boundaryReshapeScope(ceilingId))
} else {
scope.endIf((s) => s.kind === 'reshaping' && s.reshape === 'boundary')
ownsPolygonPreviewRef.current = false ownsPolygonPreviewRef.current = false
clearCeilingSnapFeedback() clearCeilingSnapFeedback()
} }
setCeilingHandleHover(isDragging) setCeilingHandleHover(isDragging)
}, },
[setCeilingHandleHover], [ceilingId, setCeilingHandleHover],
) )
const handlePolygonEditorDragCommit = useCallback(() => { const handlePolygonEditorDragCommit = useCallback(() => {
@@ -126,7 +134,6 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
levelId: ceilingLevelId, levelId: ceilingLevelId,
excludeId: ceilingId, excludeId: ceilingId,
altKey: context.nativeEvent?.altKey === true, altKey: context.nativeEvent?.altKey === true,
shiftKey: context.nativeEvent?.shiftKey === true,
}).point, }).point,
[ceilingId, ceilingLevelId], [ceilingId, ceilingLevelId],
) )
@@ -136,6 +143,9 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
clearCeilingSnapFeedback() clearCeilingSnapFeedback()
useLiveNodeOverrides.getState().clear(ceilingId) useLiveNodeOverrides.getState().clear(ceilingId)
useScene.getState().markDirty(ceilingId) useScene.getState().markDirty(ceilingId)
useInteractionScope
.getState()
.endIf((s) => s.kind === 'reshaping' && s.reshape === 'boundary')
ownsPolygonPreviewRef.current = false ownsPolygonPreviewRef.current = false
if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) { if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) {
useViewer.getState().setHoveredId(null) useViewer.getState().setHoveredId(null)
+2 -2
View File
@@ -79,6 +79,7 @@ function ceilingHandles(_node: CeilingNodeType): HandleDescriptor<CeilingNodeTyp
*/ */
export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = { export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
kind: 'ceiling', kind: 'ceiling',
snapProfile: 'structural',
schemaVersion: 1, schemaVersion: 1,
schema: CeilingNode, schema: CeilingNode,
category: 'structure', category: 'structure',
@@ -155,8 +156,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Trace ceiling outline' }, { key: 'Left click', label: 'Trace ceiling outline' },
{ key: 'Enter', label: 'Finish ceiling' }, { key: 'Enter', label: 'Finish ceiling', minDraftVertices: 3 },
{ key: 'Shift', label: 'Free outline' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
@@ -28,8 +28,9 @@ const ceilingSnapOptions = {
levelId: resolveLevelId(node, sceneNodes), levelId: resolveLevelId(node, sceneNodes),
excludeId: node.id, excludeId: node.id,
nodes: sceneNodes, nodes: sceneNodes,
// Magnetic wall-snap/alignment gates on `isMagneticSnapActive()` (the
// `lines` mode), so no Shift bypass — Alt still force-skips alignment.
altKey: modifiers.altKey, altKey: modifiers.altKey,
shiftKey: modifiers.shiftKey,
}).point }).point
}, },
} }
+6 -7
View File
@@ -16,6 +16,7 @@ import {
import { import {
CursorSphere, CursorSphere,
consumePlacementDragRelease, consumePlacementDragRelease,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
@@ -37,7 +38,7 @@ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from
* mesh's X/Z position on rebuild (`mesh.position.x = 0`, * mesh's X/Z position on rebuild (`mesh.position.x = 0`,
* `mesh.position.z = 0`) so the visual transitions smoothly. * `mesh.position.z = 0`) so the visual transitions smoothly.
* *
* Snaps to the editor's configured grid step (Shift bypasses). * Snaps to the editor's configured grid step.
*/ */
function snap(value: number) { function snap(value: number) {
return snapScalar(value, useEditor.getState().gridSnapStep) return snapScalar(value, useEditor.getState().gridSnapStep)
@@ -149,12 +150,10 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return if (isFloorplanSourcedEvent(event)) return
const bypassSnap = event.nativeEvent?.shiftKey === true const localX = snap(event.localPosition[0])
const localX = bypassSnap ? event.localPosition[0] : snap(event.localPosition[0]) const localZ = snap(event.localPosition[2])
const localZ = bypassSnap ? event.localPosition[2] : snap(event.localPosition[2])
if ( if (
!bypassSnap &&
previousGridPosRef.current && previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) { ) {
@@ -170,8 +169,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
// Figma-style alignment snap: align the ceiling's translated polygon // Figma-style alignment snap: align the ceiling's translated polygon
// vertices to other objects' anchors; fold the snap into the delta and // vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alt bypasses alignment; Shift bypasses all snap. // publish a guide. Alignment follows the global magnetic snap mode.
const bypass = event.nativeEvent?.altKey === true || bypassSnap const bypass = !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) { if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({ const result = resolveAlignment({
moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)), moving: polygonAnchors(ceilingId, translatePolygon(originalPolygon, deltaX, deltaZ)),
+33 -15
View File
@@ -4,11 +4,14 @@ import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import { import {
ActionButton, ActionButton,
ActionGroup, ActionGroup,
holeEditScope,
PanelSection, PanelSection,
PanelWrapper, PanelWrapper,
SliderControl, SliderControl,
triggerSFX, triggerSFX,
useEditingHole,
useEditor, useEditor,
useInteractionScope,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { Edit, Move, Plus, Trash2 } from 'lucide-react'
@@ -25,8 +28,7 @@ import { useCallback, useEffect, useRef } from 'react'
export function CeilingPanel() { export function CeilingPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const editingHole = useEditor((s) => s.editingHole) const editingHole = useEditingHole()
const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) => const node = useScene((s) =>
@@ -48,20 +50,26 @@ export function CeilingPanel() {
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
setEditingHole(null) useInteractionScope
}, [setSelection, setEditingHole]) .getState()
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
}, [setSelection])
useEffect(() => { useEffect(() => {
if (!node) { if (!node) {
setEditingHole(null) useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
} }
}, [node, setEditingHole]) }, [node])
useEffect(() => { useEffect(() => {
return () => { return () => {
setEditingHole(null) useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
} }
}, [setEditingHole]) }, [])
const handleAddHole = useCallback(() => { const handleAddHole = useCallback(() => {
if (!(node && selectedId)) return if (!(node && selectedId)) return
@@ -91,15 +99,17 @@ export function CeilingPanel() {
holes: [...currentHoles, newHole], holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }], holeMetadata: [...currentMetadata, { source: 'manual' }],
}) })
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) useInteractionScope
}, [node, selectedId, handleUpdate, setEditingHole]) .getState()
.begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
}, [node, selectedId, handleUpdate])
const handleEditHole = useCallback( const handleEditHole = useCallback(
(index: number) => { (index: number) => {
if (!selectedId) return if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index }) useInteractionScope.getState().begin(holeEditScope({ nodeId: selectedId, holeIndex: index }))
}, },
[selectedId, setEditingHole], [selectedId],
) )
const handleDeleteHole = useCallback( const handleDeleteHole = useCallback(
@@ -114,10 +124,12 @@ export function CeilingPanel() {
const newMetadata = currentMetadata.filter((_, i) => i !== index) const newMetadata = currentMetadata.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles, holeMetadata: newMetadata }) handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) { if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null) useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
} }
}, },
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole],
) )
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
@@ -213,7 +225,13 @@ export function CeilingPanel() {
<ActionButton <ActionButton
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90" className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
label="Done" label="Done"
onClick={() => setEditingHole(null)} onClick={() =>
useInteractionScope
.getState()
.endIf(
(scope) => scope.kind === 'reshaping' && scope.reshape === 'hole',
)
}
/> />
) : isAutoHole ? ( ) : isAutoHole ? (
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground"> <div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
+17 -37
View File
@@ -13,6 +13,9 @@ import {
CursorSphere, CursorSphere,
clearCeilingSnapFeedback, clearCeilingSnapFeedback,
EDITOR_LAYER, EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
resolveCeilingPlanPointSnap, resolveCeilingPlanPointSnap,
triggerSFX, triggerSFX,
@@ -30,7 +33,6 @@ import { CeilingNode } from './schema'
* Multi-click polygon drawing at the ceiling height (2.52m default) * Multi-click polygon drawing at the ceiling height (2.52m default)
* with a vertical TSL-gradient connector + ground-shadow lines so the * with a vertical TSL-gradient connector + ground-shadow lines so the
* draft is visible against both the ceiling plane and the floor. * draft is visible against both the ceiling plane and the floor.
* Shift defeats the 15° angle snap during drag.
*/ */
const CEILING_HEIGHT = 2.52 const CEILING_HEIGHT = 2.52
@@ -65,7 +67,6 @@ export const CeilingTool: React.FC = () => {
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0) const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null) const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Clear preset-seeded defaults on deactivation so a later manual ceiling // Clear preset-seeded defaults on deactivation so a later manual ceiling
// draw isn't built with a stale preset's parameters. Unmount-only. // draw isn't built with a stale preset's parameters. Unmount-only.
@@ -73,6 +74,12 @@ export const CeilingTool: React.FC = () => {
useEffect(() => () => clearCeilingSnapFeedback(), []) useEffect(() => () => clearCeilingSnapFeedback(), [])
// Publish the live vertex count so the HUD shows "Finish" only at ≥ 3 points.
useEffect(() => {
useEditor.getState().setDraftVertexCount(points.length)
}, [points.length])
useEffect(() => () => useEditor.getState().setDraftVertexCount(0), [])
const verticalGeo = useMemo( const verticalGeo = useMemo(
() => () =>
new BufferGeometry().setFromPoints([ new BufferGeometry().setFromPoints([
@@ -93,38 +100,27 @@ export const CeilingTool: React.FC = () => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && gridCursorRef.current)) return if (!(cursorRef.current && gridCursorRef.current)) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true // Honour the active snapping mode: grid lattice + 15° angle lock are each
const gridPosition: [number, number] = bypassSnap // gated on the mode (off / lines → free), like the slab tool.
? rawPoint const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
: [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)] const gridPosition: [number, number] = [...snapPointToGrid(rawPoint, gridStep)]
setCursorPosition(gridPosition) setCursorPosition(gridPosition)
setLevelY(event.localPosition[1]) setLevelY(event.localPosition[1])
const ceilingY = event.localPosition[1] + CEILING_HEIGHT const ceilingY = event.localPosition[1] + CEILING_HEIGHT
const gridY = event.localPosition[1] + GRID_OFFSET const gridY = event.localPosition[1] + GRID_OFFSET
const lastPoint = points[points.length - 1] const lastPoint = points[points.length - 1]
// 15° angle snap from the raw cursor (matching the 2D floorplan
// pipeline) with the distance snapped along the ray to the grid step.
const orthoPoint: [number, number] = const orthoPoint: [number, number] =
bypassSnap || !lastPoint isAngleSnapActive() && lastPoint
? gridPosition ? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)]
: [ : gridPosition
...snapPointAlongAngleRay(
lastPoint,
rawPoint,
DEFAULT_ANGLE_STEP,
useEditor.getState().gridSnapStep,
),
]
const displayPoint = resolveCeilingPlanPointSnap({ const displayPoint = resolveCeilingPlanPointSnap({
rawPoint, rawPoint,
fallbackPoint: orthoPoint, fallbackPoint: orthoPoint,
levelId: currentLevelId, levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true, altKey: !isMagneticSnapActive(),
shiftKey: bypassSnap,
}).point }).point
setSnappedCursorPosition(displayPoint) setSnappedCursorPosition(displayPoint)
if ( if (
!bypassSnap &&
points.length > 0 && points.length > 0 &&
previousSnappedPointRef.current && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || (displayPoint[0] !== previousSnappedPointRef.current[0] ||
@@ -178,28 +174,12 @@ export const CeilingTool: React.FC = () => {
clearCeilingSnapFeedback() clearCeilingSnapFeedback()
} }
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = true
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftPressed.current = false
}
const onWindowBlur = () => {
shiftPressed.current = false
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick) emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
return () => { return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
+23 -16
View File
@@ -298,20 +298,24 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
/** /**
* Column — Stage A registration. Wrap-export of the legacy * Column — Stage A registration. Wrap-export of the legacy
* `ColumnRenderer` (no system — column geometry is computed inline in * `ColumnRenderer` (no system — column geometry is computed inline in
* the renderer). Inspector / move / floorplan still go through legacy * the renderer). Inspector / floorplan still go through legacy paths via
* paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx * panel-manager.tsx / floorplan-panel.tsx (their hardcoded `case 'column':`
* (their hardcoded `case 'column':` entries fire before the registry * entries fire before the registry fallback).
* fallback).
* *
* Capabilities: column doesn't declare `movable` because its move is * Capabilities: column declares the generic `movable` (translate on XZ
* bespoke (legacy MoveColumnTool snaps to slab + free placement on * with grid snap), so its 3D move runs through the shared
* the X/Z plane with rotation). * `MoveRegistryNodeTool` — which gives it grid/line/off snapping, alignment,
* R/T rotation, slab-elevation lift, and the `collides` red/green placement
* box for free. (2D move still routes through `floorplanMoveTarget`, which
* wins the 2D dispatch.)
* *
* Defaults computed via stub-parse so we leverage every zod * Defaults computed via stub-parse so we leverage every zod
* `.default()` annotation on the schema (~60 fields). * `.default()` annotation on the schema (~60 fields).
*/ */
export const columnDefinition: NodeDefinition<typeof ColumnNode> = { export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'column', kind: 'column',
snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1, schemaVersion: 1,
schema: ColumnNode, schema: ColumnNode,
category: 'structure', category: 'structure',
@@ -327,19 +331,29 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
selectable: { hitVolume: 'bbox' }, selectable: { hitVolume: 'bbox' },
duplicable: true, duplicable: true,
deletable: true, deletable: true,
// Generic 3D translate-on-XZ via `MoveRegistryNodeTool` (grid snap + the
// mode-driven snapping the overhaul standardised). 2D move keeps using
// `floorplanMoveTarget`, which wins the 2D move dispatch.
movable: { axes: ['x', 'z'], gridSnap: true },
slots: (node) => columnSlots(node as ColumnNodeType), slots: (node) => columnSlots(node as ColumnNodeType),
paint: columnPaint, paint: columnPaint,
// Slab elevation lift via the generic `<FloorElevationSystem>`. // Slab elevation lift via the generic `<FloorElevationSystem>` + the
// placement/collision box. Use the VISIBLE footprint (round → radius,
// square → width, rectangular → width/depth, plus brace spread) so the
// box, slab-overlap, and collision all track the real column size rather
// than the raw width/depth (stale for a round column resized by radius).
floorPlaced: { floorPlaced: {
footprint: (node) => { footprint: (node) => {
const column = node as ColumnNodeType const column = node as ColumnNodeType
const { halfX, halfZ } = columnFootprintHalf(column)
return { return {
dimensions: [column.width, column.height, column.depth] as [number, number, number], dimensions: [halfX * 2, column.height, halfZ * 2] as [number, number, number],
// Column stores Y rotation as a scalar; the slab-overlap query // Column stores Y rotation as a scalar; the slab-overlap query
// expects the full Euler tuple. // expects the full Euler tuple.
rotation: [0, column.rotation, 0] as [number, number, number], rotation: [0, column.rotation, 0] as [number, number, number],
} }
}, },
collides: true,
}, },
}, },
@@ -350,12 +364,6 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'parametric', kind: 'parametric',
module: () => import('./renderer'), module: () => import('./renderer'),
}, },
// Stage D — 3D move-tool (registry-driven). Replaces the legacy
// `MoveColumnTool` in editor's dispatcher. Same 0.5m grid snap +
// live-transform preview the legacy used.
affordanceTools: {
move: () => import('./move-tool'),
},
// Registry-driven placement tool — renders a translucent `ColumnPreview` // Registry-driven placement tool — renders a translucent `ColumnPreview`
// ghost at the cursor (mirroring the shelf build tool) instead of the // ghost at the cursor (mirroring the shelf build tool) instead of the
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s // bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s
@@ -363,7 +371,6 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
tool: () => import('./tool'), tool: () => import('./tool'),
toolHints: [ toolHints: [
{ key: 'Left click', label: 'Place column' }, { key: 'Left click', label: 'Place column' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
floorplan: buildColumnFloorplan, floorplan: buildColumnFloorplan,
@@ -4,6 +4,7 @@ import {
type FloorplanAffordance, type FloorplanAffordance,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
// Floor minimums — mirror the 3D handles in `column/definition.ts` so a // Floor minimums — mirror the 3D handles in `column/definition.ts` so a
// drag can't push a value past what the renderer accepts. // drag can't push a value past what the renderer accepts.
@@ -146,11 +147,13 @@ export const columnRotateAffordance: FloorplanAffordance<ColumnNode> = {
return { return {
affectedIds: [columnId], affectedIds: [columnId],
apply({ planPoint }) { apply({ planPoint, modifiers }) {
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx) const delta = rotateAffordanceDelta({
let delta = currentAngle - initialAngle center: [cx, cz],
while (delta > Math.PI) delta -= 2 * Math.PI initialAngle,
while (delta < -Math.PI) delta += 2 * Math.PI planPoint,
free: modifiers.shiftKey,
})
const newRotation = initialRotation - delta const newRotation = initialRotation - delta
lastRotation = newRotation lastRotation = newRotation
useScene.getState().updateNode(columnId, { rotation: newRotation }) useScene.getState().updateNode(columnId, { rotation: newRotation })

Some files were not shown because too many files have changed in this diff Show More