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/node-schemas.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.
@@ -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`.
- **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.
- **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
@@ -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.
- 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".
- **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
@@ -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.
- `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
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 { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
Tooltip,
TooltipContent,
@@ -152,15 +152,19 @@ function activateRoofFeatureTool(kind: string): void {
* with the kind's own `def.defaults()`. The "Painting" type swaps in the
* material-paint panel.
*/
// MEP tool kinds that, when active, mean the MEP group tile (and its sub-grid)
// is what the user is working in.
const MEP_TOOL_KINDS = new Set<string>([
...MEP_ITEMS.map((item) => item.kind),
'duct-fitting',
'pipe-fitting',
])
export function BuildTab() {
const activeTool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
// Which build tile's panel is showing. Roof (Features) and MEP (its tool
// sub-grid) are the tiles with a panel; others arm a tool and show nothing
// below.
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null)
// The fitting / follow tools are armed from a segment's panel, not a grid
// tile — keep the segment tile lit so the panel (and the way back) stays
@@ -201,8 +205,23 @@ export function BuildTab() {
return features
}, [])
const isTypeActive = (type: BuildType) =>
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id
// Tile highlight derives from the single source of truth (the active tool /
// mode), never a separate local selection — so keyboard shortcuts and panel
// clicks always agree on which tile is lit.
// The roof Features sub-grid arms roof-accessory tools (skylight, chimney,
// …); keep the Roof tile lit (and its panel open) while any of them is the
// active tool, the same way MEP stays lit for its sub-grid tools.
const isRoofFeatureActive =
mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool)
const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)
const isTypeActive = (type: BuildType) => {
if (type.mode === 'material-paint') return mode === 'material-paint'
if (type.id === 'mep') return isMepActive
if (type.id === 'roof')
return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive)
return mode === 'build' && activeTool === type.kind
}
const handleTypeClick = useCallback((type: BuildType) => {
if (type.mode === 'material-paint') {
@@ -214,15 +233,18 @@ export function BuildTab() {
} else if (type.kind) {
activateBuildTool(type.kind)
}
setSelectedTypeId(type.id)
}, [])
// On open, land on the first build tool — parity with the community Build
// sidebar, so switching to Build immediately arms a usable tool.
// sidebar, so switching to Build immediately arms a usable tool. Skip when a
// build tool is already active (e.g. the B shortcut armed one before this
// panel mounted): the active tool is the source of truth, not this default.
const didInitRef = useRef(false)
useEffect(() => {
if (didInitRef.current) return
didInitRef.current = true
const ed = useEditor.getState()
if (ed.mode === 'build' && ed.tool) return
const firstType = BUILD_TYPES.find((t) => t.kind)
if (firstType) handleTypeClick(firstType)
}, [handleTypeClick])
@@ -275,7 +297,9 @@ export function BuildTab() {
<div className="min-h-0 flex-1 overflow-y-auto">
<MaterialPaintPanel />
</div>
) : selectedTypeId === 'roof' && roofFeatures.length > 0 ? (
) : mode === 'build' &&
(activeTool === 'roof' || isRoofFeatureActive) &&
roofFeatures.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
<TooltipProvider delayDuration={0} disableHoverableContent>
@@ -320,7 +344,7 @@ export function BuildTab() {
</div>
</TooltipProvider>
</div>
) : selectedTypeId === 'mep' ? (
) : isMepActive ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div>
<TooltipProvider delayDuration={0} disableHoverableContent>
@@ -1,8 +1,10 @@
import { nodeRegistry } from '../../registry'
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
import useScene from '../../store/use-scene'
import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve'
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { SpatialGrid } from './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 = {
min: [number, number, number]
max: [number, number, number]
@@ -647,34 +672,38 @@ export class SpatialGridManager {
) {
const nodes = useScene.getState().nodes
const ignoreSet = new Set(ignoreIds ?? [])
const [width, , depth] = dimensions
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,
}
const draftBounds = footprintBoundsXZ(position, dimensions, rotation[1])
// 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[] = []
for (const node of Object.values(nodes)) {
if (node.type !== 'item') continue
const item = node as ItemNode
if (item.asset.attachTo) continue
if (isLowProfileItemSurface(item)) continue
if (ignoreSet.has(item.id)) continue
if (resolveNodeLevelId(item, nodes) !== levelId) continue
if (ignoreSet.has(node.id)) continue
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced?.collides) continue
if (floorPlaced.applies && !floorPlaced.applies(node)) continue
// Low-profile item surfaces (rugs, mats) are stack-on targets, not
// 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)
if (
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)
) {
conflicts.push(item.id)
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 (
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)
) {
conflicts.push(node.id)
break
}
}
}
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode } from '../schema'
import { planAutoCeilingsForLevel } from './space-detection'
import { planAutoCeilingsForLevel, planAutoSlabsForLevel } from './space-detection'
const square: Array<[number, number]> = [
[0, 0],
@@ -90,3 +90,26 @@ describe('planAutoCeilingsForLevel', () => {
expect(plan.update).toHaveLength(0)
})
})
describe('planAutoSlabsForLevel', () => {
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
// Two rooms with identical polygon signatures previously collided in a
// signature-keyed Map, so one detected room never matched an existing slab
// and churned (delete + recreate) on every pass.
const slabA = slab(0.05)
const slabB = slab(0.05)
const plan = planAutoSlabsForLevel([roomPolygon(), roomPolygon()], [slabA, slabB])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
expect(plan.update).toHaveLength(0)
})
test('deletes an extra auto-slab when only one identical room is detected', () => {
const plan = planAutoSlabsForLevel([roomPolygon()], [slab(0.05), slab(0.05)])
expect(plan.create).toHaveLength(0)
expect(plan.delete).toHaveLength(1)
})
})
+22 -36
View File
@@ -595,43 +595,25 @@ function levelWallSnapshot(walls: WallNode[]) {
return walls.map(wallGeometrySignature).sort().join('||')
}
function slabGeometrySignature(slab: SlabNodeType) {
const polygon = slab.polygon
.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`)
.join(';')
const holes = (slab.holes ?? [])
.map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';'))
.join('/')
return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join(
'|',
)
}
function levelSlabSnapshot(slabs: SlabNodeType[]) {
return slabs.map(slabGeometrySignature).sort().join('||')
}
// Trigger signature is wall-only on purpose: re-detection should fire on a
// genuine remodel (wall geometry change), never when an auto-slab is edited or
// deleted. Hashing slabs here created a feedback loop where deleting an
// auto-slab re-fired detection and recreated it.
function levelStructureSnapshots(nodes: Record<string, any>) {
const byLevel = new Map<string, { walls: WallNode[]; slabs: SlabNodeType[] }>()
const getEntry = (levelId: string) => {
const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] }
byLevel.set(levelId, entry)
return entry
}
const byLevel = new Map<string, WallNode[]>()
for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
if ((node as any).type === 'wall') {
getEntry((node as any).parentId).walls.push(node as WallNode)
} else if ((node as any).type === 'slab') {
getEntry((node as any).parentId).slabs.push(SlabNode.parse(node))
}
if ((node as any).type !== 'wall') continue
const levelId = (node as any).parentId as string
const walls = byLevel.get(levelId) ?? []
walls.push(node as WallNode)
byLevel.set(levelId, walls)
}
const snapshots = new Map<string, string>()
for (const [levelId, entry] of byLevel.entries()) {
snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`)
for (const [levelId, walls] of byLevel.entries()) {
snapshots.set(levelId, levelWallSnapshot(walls))
}
return snapshots
@@ -692,13 +674,15 @@ export function planAutoSlabsForLevel(
const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, [number, number][]>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry)
const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
}
detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig)
const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return
matchedDetectedIdx.add(index)
@@ -875,13 +859,15 @@ export function planAutoCeilingsForLevel(
const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) {
autoBySignature.set(entry.sig, entry)
const bucket = autoBySignature.get(entry.sig) ?? []
bucket.push(entry)
autoBySignature.set(entry.sig, bucket)
}
detected.forEach((room, index) => {
const existing = autoBySignature.get(room.sig)
const existing = autoBySignature.get(room.sig)?.shift()
if (!existing) return
matchedDetectedIdx.add(index)
+2
View File
@@ -31,6 +31,7 @@ export {
nodeRegistry,
type PluginDiscovery,
registerNode,
resolveFacingIndicator,
setPluginDiscovery,
} from './registry'
export {
@@ -109,6 +110,7 @@ export type {
SelectableConfig,
SlotDeclaration,
SnapPointKind,
SnapProfile,
SnappableConfig,
SnapServicesLike,
SurfacePoint,
+12
View File
@@ -178,6 +178,18 @@ export function isPresettableKind(kind: string): boolean {
return def ? isPresettable(def) : false
}
/**
* Resolve a kind's facing-triangle config, or `null` when it has none.
* `{ reversed }` says whether the triangle points along the node's local -Z
* (its front) instead of +Z. One reader (the editor-side `<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`,
* `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
// without importing `useScene`. Builders stay pure and unit-testable.
//
// Future extension: `levelData?: { miters?: ... }` for level-scoped batch
// data (wall mitering across an entire level). Decided alongside the wall
// migration off its dedicated system (Phase 3+).
// `levelData` carries level-scoped batch data (wall mitering across an
// entire level) from registry dispatchers into pure builders.
export type GeometryContext = {
/** Look up any node by ID. Returns undefined if the node doesn't exist. */
@@ -30,18 +29,16 @@ export type GeometryContext = {
parent: AnyNode | null
/**
* Pre-computed level-batch data, populated by the dispatcher when the
* kind declares `def.computeLevelData`. Shared across every
* `def.geometry(node, ctx)` call in the same level batch within a
* single frame, so kinds whose geometry depends on cross-sibling
* data (wall mitering, gradient sky uniforms across a zone, etc.)
* don't pay an O(N²) recomputation cost.
* kind declares `def.computeLevelData` (3D) or
* `def.computeFloorplanLevelData` (2D). Shared across every builder call
* in the same level batch within a single frame/render pass, so kinds
* whose geometry depends on cross-sibling data (wall mitering, gradient
* sky uniforms across a zone, etc.) don't pay an O(N²) recomputation cost.
*
* Typed as `unknown` at the framework boundary — kinds cast to their
* own `LevelData` shape inside `def.geometry` (the same kind owns
* both the `computeLevelData` return shape and the `geometry`
* consumer, so the cast is internal). Only populated for `def.
* geometry` calls today; not used by `def.floorplan` (which already
* has cheap access to siblings through `ctx.siblings`).
* own `LevelData` shape inside `def.geometry` / `def.floorplan` (the
* same kind owns both the compute hook's return shape and the builder
* consumer, so the cast is internal).
*/
levelData?: unknown
/**
@@ -224,6 +221,13 @@ export type ToolHint = {
key: string
/** Description of what the input does. Sentence case. */
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 =
@@ -713,12 +717,29 @@ export type SurfaceRole =
/** Role a kind plays in a duct / pipe / lineset distribution system. */
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>> = {
kind: string
schemaVersion: number
schema: S
category: NodeCategory
surfaceRole?: SurfaceRole
/**
* Show a floor direction-triangle while placing/moving — the kind has a
* meaningful front. `true` points along the node's local +Z (forward).
* `{ reversed: true }` points along local -Z, for kinds whose front is the
* -Z side (a stair faces *out* of its run: you approach from the low end,
* which sits on the -Z side of the footprint).
*/
facingIndicator?: boolean | { reversed?: boolean }
/**
* Role this kind plays in a distribution system (HVAC duct / DWV pipe /
* refrigerant lineset). Lets the system-graph summary classify a
@@ -820,6 +841,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* runs once even when many walls are dirty in the same frame.
*/
computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown
/**
* Floor-plan level-batch precompute hook. The floor-plan layer calls this
* once per level per render pass, de-duplicated by kind, before the
* per-node `def.floorplan` calls. The result lands in `ctx.levelData` for
* every node of this kind in the level.
*
* Used to hoist cross-sibling floor-plan work that would otherwise be
* O(N²) when rebuilding every node in a kind — e.g. wall mitering. `nodes`
* is the live-merged scene snapshot; `siblings` is every node of this kind
* in the level, also live-merged.
*/
computeFloorplanLevelData?: (args: {
siblings: ReadonlyArray<z.infer<S>>
nodes: Record<string, AnyNode>
}) => unknown
/**
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
@@ -877,6 +913,12 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* unset and rely on the generic overlay path.
*/
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
/**
* Geometry reads sibling/parent/child nodes (e.g. wall miters, opening
* dimensions); the floor-plan layer must rebuild it whenever a
* sibling-affecting node is being dragged live.
*/
floorplanDependsOnSiblings?: boolean
/**
* Optional hook letting a kind project the `useLiveNodeOverrides` map
* into a fresh `nodes` snapshot before its `def.floorplan` builder
@@ -940,6 +982,29 @@ export type NodeDefinition<S extends ZodObject<any>> = {
*/
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
* show where the node will land, and by the placement tool's cursor.
@@ -1249,6 +1314,13 @@ export type SlotDeclaration = {
}
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`
* when the face shouldn't be painted (e.g. interior slot exposed
@@ -1522,6 +1594,15 @@ export type FloorPlacedConfig = {
footprint?: FloorPlacedFootprintResolver
footprints?: FloorPlacedFootprintsResolver
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 {
canAttach,
canHostOnTop,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,
@@ -14,6 +15,16 @@ import {
const id = (s: string) => s as AnyNodeId
function makeItem(idStr: string, attachTo?: 'wall' | 'wall-side' | 'ceiling'): AnyNode {
return {
id: id(idStr),
type: 'item',
parentId: null,
visible: true,
asset: attachTo ? { attachTo } : {},
} as unknown as AnyNode
}
function makeDef(
kind: string,
capabilities: Capabilities = {},
@@ -233,4 +244,30 @@ describe('pickHost', () => {
})
expect(picked?.id).toBe(id('s2'))
})
test('excludes ceiling-mounted hosts (ceiling fan cannot be a top surface)', () => {
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
const candidates = [makeItem('fan', 'ceiling'), makeItem('table')]
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
expect(picked?.id).toBe(id('table'))
})
test('keeps wall-mounted hosts (wall shelf still hosts)', () => {
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
const candidates = [makeItem('shelf', 'wall')]
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
expect(picked?.id).toBe(id('shelf'))
})
})
describe('canHostOnTop', () => {
test('rejects ceiling-attachTo hosts', () => {
expect(canHostOnTop(makeItem('fan', 'ceiling'))).toBe(false)
})
test('accepts wall / wall-side / floor (undefined) hosts', () => {
expect(canHostOnTop(makeItem('shelf', 'wall'))).toBe(true)
expect(canHostOnTop(makeItem('sconce', 'wall-side'))).toBe(true)
expect(canHostOnTop(makeItem('table'))).toBe(true)
})
})
+13 -4
View File
@@ -112,6 +112,18 @@ export function getTopSurfaceHeight(host: AnyNode): number | null {
return typeof height === 'function' ? height(host) : height
}
/**
* Whether `host` can receive a surface-resting (top-stacked) child. A
* ceiling-mounted item hangs from the ceiling, so its visible "top" is not a
* usable resting surface — nothing should stack on a ceiling fan. The check
* reads the instance-level `asset.attachTo` (not the host KIND, which is shared
* across all items) so a single gate covers every interaction path.
*/
export function canHostOnTop(host: AnyNode): boolean {
const attachTo = (host as { asset?: { attachTo?: string } }).asset?.attachTo
return attachTo !== 'ceiling'
}
/**
* Pure host-discovery helper. Given a list of candidate hosts (already
* narrowed by spatial query) and a point, returns the first whose
@@ -129,10 +141,7 @@ export function pickHost(args: {
const def = nodeRegistry.get(host.type)
const hostable = def?.capabilities.hostable
if (!hostable) continue
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) {
// capability declares specific parents; verify the placed kind's own def
// also permits this host kind.
}
if (!canHostOnTop(host)) continue
if (args.hitTest && !args.hitTest(host, args.point)) continue
return host
}
+1
View File
@@ -34,6 +34,7 @@ export {
type AttachError,
type AttachResult,
canAttach,
canHostOnTop,
clampYToHostTop,
getSurface,
getTopSurfaceHeight,
@@ -2,6 +2,11 @@
import { memo, type MouseEvent as ReactMouseEvent } from 'react'
import useEditor from '../../store/use-editor'
import {
useEndpointReshape,
useIsCurveReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu'
type SvgPoint = {
@@ -48,12 +53,11 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
offsetY = 10,
}: FloorplanActionMenuLayerProps) {
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const movingNode = useMovingNode()
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) {
if (!isFloorplanHovered || movingNode || endpointReshape || isCurveReshape) {
return null
}
@@ -16,6 +16,7 @@ import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu'
/**
@@ -46,8 +47,9 @@ import { NodeActionMenu } from '../editor/node-action-menu'
*/
export function FloorplanRegistryActionMenu() {
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 setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Gate on floorplan hover so this 2D menu never coexists with the 3D
// FloatingActionMenu in split view — that menu hides while the floorplan
// is hovered, so this one must only show then. Mirrors the legacy
@@ -141,6 +143,11 @@ export function FloorplanRegistryActionMenu() {
const handleMove = () => {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never)
// 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
// Mark the origin (after `setMovingNode`, which resets it to null) so
// `ToolManager` keeps the 3D affordance mover from also adopting the node
// and reverting it on unmount. Mirrors the orange move-dot path.
setMovingNodeOrigin('2d')
// Match the legacy 3D `floating-action-menu`: clear selection so
// selection-gated affordances unmount during the drag. Specifically
// the slab / ceiling boundary editor (`ToolManager` shows it when
@@ -23,7 +23,8 @@ import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../lib/sfx-bus'
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'
// 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.
*/
export function FloorplanRegistryMoveOverlay() {
const movingNode = useEditor((s) => s.movingNode)
const movingNode = useMovingNode()
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
@@ -508,10 +509,12 @@ export function FloorplanRegistryMoveOverlay() {
if (!m) return
// 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 snap = (value: number) =>
event.shiftKey ? value : Math.round(value / gridStep) * gridStep
isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
const resolved = resolvePlanarCursorPosition({
cursor: [m[0], m[1]],
original: [originalPosition[0], originalPosition[2]],
@@ -524,12 +527,12 @@ export function FloorplanRegistryMoveOverlay() {
// 2) Alignment snap layered on top. Treat the grid-snapped point
// as the "proposed" position so alignment competes from a stable
// base rather than the raw cursor jitter. Alt bypasses alignment
// entirely; Shift bypasses both grid and alignment
// hint chip.
// base rather than the raw cursor jitter. Alignment ("lines") follows
// the magnetic snapping mode — independent of grid; Alt is force-place,
// not a snap bypass.
let finalX = gridX
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
// moving anchors at that location. The entry's untransformed
// 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'
import { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor'
import {
useActiveHandleDrag,
useEndpointReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
const currentTarget = new Vector3()
const tempBox = new Box3()
@@ -611,18 +616,12 @@ export const CustomCameraControls = () => {
const tool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
const movingNode = useEditor((s) => s.movingNode)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
const movingNode = useMovingNode()
const endpointReshape = useEndpointReshape()
const activeHandleDrag = useActiveHandleDrag()
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
const isInteracting = Boolean(
tool ||
movingNode ||
movingWallEndpoint ||
movingFenceEndpoint ||
activeHandleDrag ||
isBoxSelectActive,
tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive,
)
const touches = useMemo(() => {
const twoFingerAction =
@@ -1154,12 +1153,12 @@ export const CustomCameraControls = () => {
}, [])
// 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
// capturing presets; reset on exit so general editing keeps the looser
// navigation guardrails.
const isPresetCapture = captureMode.mode === 'preset'
const minDistance = isPresetCapture ? 0.5 : 6
const minDistance = isPresetCapture ? 0.5 : 2
if (isFirstPersonMode) {
return null
@@ -8,6 +8,7 @@ import {
DEFAULT_WALL_HEIGHT,
DoorNode,
ElevatorNode,
emitter,
FenceNode,
generateId,
getActiveRoofHeight,
@@ -35,10 +36,17 @@ import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useCallback, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import useEditor from '../../store/use-editor'
import useInteractionScope, {
useActiveHandleDrag,
useEndpointReshape,
useIsCurveReshape,
} from '../../store/use-interaction-scope'
import { formatMeasurement, MeasurementPill } from './measurement-pill'
import { NodeActionMenu } from './node-action-menu'
@@ -137,6 +145,11 @@ function getAttributeVersion(
: 0
}
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
// dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
const _anchorBox = new THREE.Box3()
const _anchorCenter = new THREE.Vector3()
function getObjectGeometryKey(object: THREE.Object3D): string {
const parts: string[] = []
object.traverse((child) => {
@@ -203,21 +216,22 @@ export function FloatingActionMenu() {
const updateNode = useScene((s) => s.updateNode)
const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const curvingFence = useEditor((s) => s.curvingFence)
const canFindNode = useEditor((s) => s.canFindNode)
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
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 setEditingHole = useEditor((s) => s.setEditingHole)
const unit = useViewer((s) => s.unit)
// Drives the height-drag dimension pill below the menu. `activeHandleDrag`
// flips only at drag start / end, so subscribing here is cheap — the live
// 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).
const rotationAxis = useEditor((s) => s.rotationAxis)
// The floating action menu is an action-conflicting control: hard-hidden
// during any active interaction so it never competes with the live action.
const scope = useInteractionScope((s) => s.scope)
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
const groupRef = useRef<THREE.Group>(null)
const menuScaleRef = useRef<HTMLDivElement>(null)
@@ -329,23 +343,44 @@ export function FloatingActionMenu() {
// mid-resize). A spinning child changes the head's matrix, not the
// registered group's, so it never triggers a recompute → the menu
// holds still.
// Cheapest guards first: a selection swap, the object's own world
// transform changing (true every frame during a drag), a live override,
// or an active handle drag all force a recompute on their own — so skip
// the geometry traversal (`getObjectGeometryKey` walks the whole subtree
// reading attribute versions) until none of them fired and a
// geometry-only change is the only thing left that could move the anchor.
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
const dragActive = activeHandleDrag?.nodeId === selectedId
const effectiveNode = getEffectiveNode(node)
const geometryKey = getObjectGeometryKey(obj)
const selectionChanged =
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
const geometryChanged = lastAnchorKeyRef.current.geometryKey !== geometryKey
if (selectionChanged || matrixChanged || geometryChanged || overrideActive || dragActive) {
let geometryKey = lastAnchorKeyRef.current.geometryKey
let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive
// Only when nothing cheaper fired do we pay for the subtree traversal —
// a geometry-only change is the lone remaining trigger. When a cheaper
// guard already forced a recompute the stored key is reused; the matrix
// (or override/drag) keeps recomputing the anchor every frame, so a
// geometry edit mid-drag is absorbed, and the next idle frame refreshes
// the key against the live geometry.
if (!needsRecompute) {
geometryKey = getObjectGeometryKey(obj)
if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true
}
if (needsRecompute) {
const effectiveNode = getEffectiveNode(node)
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
_anchorBox.setFromObject(obj)
if (!_anchorBox.isEmpty()) {
_anchorBox.getCenter(_anchorCenter)
// Position above the object. Per-type offsets clear each kind's
// in-world chrome (height-resize arrows, measurement labels).
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(effectiveNode), center.z)
anchorRef.current.set(
_anchorCenter.x,
_anchorBox.max.y + getMenuYOffset(effectiveNode),
_anchorCenter.z,
)
hasAnchorRef.current = true
}
} else {
@@ -368,15 +403,15 @@ export function FloatingActionMenu() {
sfxEmitter.emit('sfx:item-pick')
if (node.type === 'wall') {
if (!canCurveSelectedWall) return
setCurvingWall(node)
useInteractionScope.getState().begin(curveReshapeScope(node.id))
} else if (node.type === 'fence') {
setCurvingFence(node)
useInteractionScope.getState().begin(curveReshapeScope(node.id))
} else {
return
}
setSelection({ selectedIds: [] })
},
[canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
[canCurveSelectedWall, node, setSelection],
)
const handleMove = useCallback(
(e: React.MouseEvent) => {
@@ -602,11 +637,13 @@ export function FloatingActionMenu() {
holes: [...currentHoles, newHole],
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
setSelection({ selectedIds: [selectedId] })
},
[node, selectedId, updateNode, setEditingHole, setSelection],
[node, selectedId, updateNode, setSelection],
)
const handleDelete = useCallback(
@@ -620,11 +657,21 @@ export function FloatingActionMenu() {
[node?.type, selectedId, setSelection],
)
// "Find in catalog": the editor only signals intent — the host (community)
// listens for `selection:find-node` and reveals the node in its browser.
const handleFind = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (node) emitter.emit('selection:find-node' as never, node as never)
},
[node],
)
if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint ||
movingFenceEndpoint ||
curvingFence
endpointReshape ||
isCurveReshape ||
menuStepBack
)
return null
@@ -641,6 +688,7 @@ export function FloatingActionMenu() {
>
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
<NodeActionMenu
onFind={node && canFindNode ? handleFind : undefined}
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
File diff suppressed because it is too large Load Diff
+146 -38
View File
@@ -1,14 +1,28 @@
'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 { useFrame } from '@react-three/fiber'
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 { MeshBasicNodeMaterial } from 'three/webgpu'
import { useCeilingEvents } from '../../hooks/use-ceiling-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 = ({
cellSize = 0.5,
@@ -38,6 +52,28 @@ export const Grid = ({
const effectiveSectionColor = isDark ? '#666677' : sectionColor
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(() => {
// 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
// Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => {
const getGrid = (size: number | typeof cellSizeUniform, thickness: number) => {
const r = pos.div(size)
const fw = fwidth(r)
// Distance to nearest grid line for each axis
@@ -70,7 +106,7 @@ export const Grid = ({
return lineX.max(lineY)
}
const g1 = getGrid(cellSize, cellThickness)
const g1 = getGrid(cellSizeUniform, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center
@@ -79,7 +115,9 @@ export const Grid = ({
// Cursor reveal effect - distance from cursor
const cursorDist = pos.sub(cursorPos).length()
const cursorFade = float(1).sub(cursorDist.div(revealRadius).clamp(0, 1)).smoothstep(0, 1)
const cursorFade = float(1)
.sub(cursorDist.div(revealRadiusUniform).clamp(0, 1))
.smoothstep(0, 1)
// Mix colors based on section grid
const gridColor = mix(
@@ -88,21 +126,26 @@ export const Grid = ({
float(sectionThickness).mul(g2).min(1),
)
// Baseline alpha: small amount of opacity everywhere the grid exists
const baseAlpha = float(0.4) // Subtle global visibility
// Combined alpha with cursor fade and baseline minimum
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha))
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlphaUniform))
const boostedAlpha = alpha.mul(patchAlphaUniform).min(1)
const finalAlpha = mix(boostedAlpha.mul(0.75), boostedAlpha, g2)
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: gridColor,
opacityNode: finalAlpha,
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,
effectiveCellColor,
sectionSize,
@@ -110,7 +153,10 @@ export const Grid = ({
effectiveSectionColor,
fadeDistance,
fadeStrength,
revealRadius,
revealRadiusUniform,
baseAlphaUniform,
cellSizeUniform,
patchAlphaUniform,
])
const gridRef = useRef<Mesh>(null!)
@@ -124,13 +170,10 @@ export const Grid = ({
useCeilingEvents()
// Track the last world-space cursor hit. The reveal-fade shader reads
// `positionLocal.xy` (vertex position on the un-transformed plane), and
// the mesh's -π/2 X rotation maps `positionLocal.y` to world `-Z`
// relative to the mesh origin. The mesh origin itself is lerped each
// frame toward the active building's world XZ (see `useFrame` below),
// 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).
// `positionLocal.xy` (vertex position on the un-transformed plane), and the
// laid-flat orientation maps `positionLocal.y` to world `-Z` relative to the
// mesh origin. The cursor is recomputed every frame from the stored world hit
// so the reveal stays put regardless of where the mesh origin sits.
const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
useEffect(() => {
const onGridMove = (event: GridEvent) => {
@@ -143,32 +186,96 @@ export const Grid = ({
}
}, [])
useFrame((_, delta) => {
useFrame(() => {
const { levelId } = useViewer.getState().selection
// Grid stays anchored to world XZ (0, 0) — never chases the active
// building. The Y origin still lerps to the active level so the grid
// sits at floor height when a level is open.
let targetY = 0
let levelY = 0
if (levelId) {
const levelMesh = sceneRegistry.nodes.get(levelId)
if (levelMesh) {
targetY = levelMesh.position.y
levelY = levelMesh.position.y
}
}
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
gridRef.current.position.y = newY
setGridY(newY)
// Grid XZ is fixed at world origin, so the local-frame cursor uniform
// is just the world cursor (mirrored on Z to match the -π/2 X-rotation
// of the plane).
const world = lastWorldCursorRef.current
if (world) {
cursorPositionRef.current.set(world.x, -world.z)
// Resolve the surface the active ghost is snapped to (contact point +
// normal). A fresh GLB item / drawn kind publishes via the surface module; a
// moving node is read straight off its mesh (treated as horizontal). Null
// when nothing is being placed.
const published = getPlacementSurface()
const movingForGrid = getMovingNode()
let surfacePoint: Vector3 | null = null
let surfaceNormal = UP
if (published) {
surfacePoint = published.point
surfaceNormal = published.normal
} else if (movingForGrid) {
const ghostMesh = sceneRegistry.nodes.get(movingForGrid.id as AnyNodeId)
if (ghostMesh) surfacePoint = ghostMesh.getWorldPosition(worldPosRef.current)
}
})
const showGrid = useViewer((state) => state.showGrid)
const gridMesh = gridRef.current
const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
if (onWall && surfacePoint) {
// 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
if (world) {
cursorPositionRef.current.set(world.x, -world.z)
}
if (lastGridYRef.current !== targetY) {
lastGridYRef.current = targetY
setGridY(targetY)
}
}
// 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
// is never reconciled with R3F's empty placeholder `BufferGeometry`.
@@ -183,13 +290,14 @@ export const Grid = ({
useEffect(() => () => geometry.dispose(), [geometry])
return (
// Orientation is driven imperatively in `useFrame` (horizontal by default,
// tilted into the wall plane while placing on a wall), so no static rotation.
<mesh
geometry={geometry}
layers={GRID_LAYER}
material={material}
ref={gridRef}
rotation-x={-Math.PI / 2}
visible={showGrid}
renderOrder={1}
/>
)
}
@@ -13,6 +13,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import {
CORNER_OFFSET,
@@ -44,7 +45,7 @@ export function GroupMoveHandle() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const levelId = useViewer((s) => s.selection.levelId)
const mode = useEditor((s) => s.mode)
const movingNode = useEditor((s) => s.movingNode)
const movingNode = useMovingNode()
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
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 { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import {
CORNER_OFFSET,
@@ -55,7 +56,7 @@ export function GroupRotateHandle() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const levelId = useViewer((s) => s.selection.levelId)
const mode = useEditor((s) => s.mode)
const movingNode = useEditor((s) => s.movingNode)
const movingNode = useMovingNode()
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
// Re-derive participants whenever the scene mutates (e.g. after a commit).
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
@@ -279,15 +279,27 @@ export function createArrowHitAreaGeometry() {
return geometry
}
// The move cross is a plus, not a disk. A disk-shaped hit area fills the four
// corner gaps between the arms, so a neighbouring node sitting next to the
// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the
// invisible grip and can't be picked. Wrap the visible arms instead: two flat
// arm boxes (length/width + margin) merged into a plus, leaving the corners
// empty so co-located neighbours stay selectable while the grip stays grabbable.
function createMoveCrossHitAreaGeometry() {
const geometry = new CylinderGeometry(
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
HIT_AREA_THICKNESS,
32,
)
geometry.computeBoundingSphere()
return geometry
const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2
const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth)
const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength)
const merged = mergeGeometries([armX, armZ], false)
if (!merged) {
armZ.dispose()
armX.computeBoundingSphere()
return armX
}
armX.dispose()
armZ.dispose()
merged.computeBoundingSphere()
return merged
}
export function createRotateArrowHitAreaGeometry() {
+123 -9
View File
@@ -2,6 +2,9 @@
import { Icon } from '@iconify/react'
import {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getSceneMaterialIdFromRef,
initSpaceDetectionSync,
initSpatialGridSync,
spatialGridManager,
@@ -19,6 +22,7 @@ import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard'
import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint'
import {
applySceneGraphToEditor,
loadSceneFromLocalStorage,
@@ -81,6 +85,7 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const SCENE_READY_FALLBACK_MS = 8000
type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked'
const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
@@ -535,14 +540,70 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
)
}
function getActivePaintMaterialSwatchColor(
material: ActivePaintMaterial | null,
sceneMaterials: ReturnType<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({
position,
disabled,
state,
swatchColor,
swatchImageUrl,
isEraser,
}: {
position: { x: number; y: number }
disabled: boolean
state: PaintCursorBadgeState
swatchColor: string
swatchImageUrl?: string
isEraser: boolean
}) {
const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR
const accentColor =
state === 'ready'
? isEraser
? PAINT_CURSOR_BADGE_COLOR
: swatchColor
: PAINT_CURSOR_BADGE_DISABLED_COLOR
const iconOpacity = state === 'ready' ? 1 : state === 'blocked' ? 0.62 : 0.42
const lineHeight = 18
return (
@@ -576,7 +637,48 @@ function PaintCursorBadge({
aria-hidden="true"
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
src="/icons/paint.webp"
style={{
filter: state === 'ready' ? undefined : 'grayscale(1)',
opacity: iconOpacity,
}}
/>
{state === 'ready' ? (
isEraser ? (
<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>
)
@@ -730,6 +832,9 @@ function PaintCursorLayer({
}) {
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
const paintEraser = useEditor((s) => s.paintEraser)
const paintHover = useEditor((s) => s.paintHover)
const sceneMaterials = useScene((s) => s.materials)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const active = mode === 'material-paint' && !isVersionPreviewMode
@@ -779,11 +884,14 @@ function PaintCursorLayer({
}
}, [active, containerRef])
const hasMaterial = Boolean(
activePaintMaterial &&
(activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined),
)
const hasPaint = paintEraser || hasActivePaintMaterial(activePaintMaterial)
const badgeState: PaintCursorBadgeState = !hasPaint
? 'empty'
: paintHover != null
? 'ready'
: 'blocked'
const swatchColor = getActivePaintMaterialSwatchColor(activePaintMaterial, sceneMaterials)
const swatchImageUrl = getActivePaintMaterialSwatchImageUrl(activePaintMaterial, sceneMaterials)
if (!active || !position) return null
@@ -792,7 +900,13 @@ function PaintCursorLayer({
className="pointer-events-none absolute z-40"
style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }}
>
<PaintCursorBadge disabled={!hasMaterial} position={{ x: 0, y: 0 }} />
<PaintCursorBadge
isEraser={paintEraser}
position={{ x: 0, y: 0 }}
state={badgeState}
swatchColor={swatchColor}
swatchImageUrl={swatchImageUrl}
/>
</div>
)
}
@@ -1,10 +1,11 @@
'use client'
import { Icon } from '@iconify/react'
import { Copy, Move, Spline, Trash2 } from 'lucide-react'
import { Copy, Move, Search, Spline, Trash2 } from 'lucide-react'
import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = {
onFind?: MouseEventHandler<HTMLButtonElement>
onAddHole?: MouseEventHandler<HTMLButtonElement>
onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement>
@@ -17,6 +18,7 @@ type NodeActionMenuProps = {
}
export function NodeActionMenu({
onFind,
onAddHole,
onDelete,
onDuplicate,
@@ -35,6 +37,17 @@ export function NodeActionMenu({
onPointerLeave={onPointerLeave}
onPointerUp={onPointerUp}
>
{onFind && (
<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 && (
<button
aria-label="Move"
@@ -16,7 +16,6 @@ import {
sceneRegistry,
snapScalar,
type TapActionHandle,
type TranslateHandle,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
@@ -44,12 +43,17 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu'
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 { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor from '../../store/use-editor'
import useInteractionScope, {
useEndpointReshape,
useIsCurveReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import useOpeningGuides from '../../store/use-opening-guides'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { formatAngleRadians } from '../tools/shared/segment-angle'
import {
ARROW_COLOR,
@@ -182,16 +186,13 @@ export function NodeArrowHandles() {
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const placementDragMode = useEditor((state) => state.placementDragMode)
const movingNode = useMovingNode()
// Endpoint / curve drags reshape the selected wall or fence; hide its
// resize arrows for the duration so they don't clutter (or get blocked
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
// on the legacy wall handles (`WallMoveSideHandles`).
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
const rawNode = useScene((state) =>
@@ -209,26 +210,31 @@ export function NodeArrowHandles() {
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
[rawNode, liveOverride],
)
const isOwnPressDragMove =
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
const def = node ? nodeRegistry.get(node.type) : null
const descriptors = useMemo(() => {
if (!(node && def?.handles)) return null
return typeof def.handles === 'function'
? def.handles(node as never)
: (def.handles as HandleDescriptor[])
const all =
typeof def.handles === 'function'
? def.handles(node as never)
: (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])
const shouldRender =
Boolean(node && descriptors?.length) &&
!isFloorplanHovered &&
mode !== 'delete' &&
(!movingNode || isOwnPressDragMove) &&
!movingWallEndpoint &&
!movingFenceEndpoint &&
!curvingWall &&
!curvingFence
// Any whole-node move (placement or press-drag) hides the rig: the item is
// following the cursor, so its rotate/resize handles would only clutter and
// draw stray selection rays. The active handle-drag scope (resize/rotate)
// sets `activeHandleDrag`, not `movingNode`, so those are unaffected.
!movingNode &&
!endpointReshape &&
!isCurveReshape
if (!shouldRender || !node || !descriptors) return null
// 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
// here, or they'd lag behind the moving item.
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
// While a rotate gizmo is mid-drag, drop the opposite-side move cross: you
// can't move and rotate at once, so it only clutters the rotation.
const activeDescriptor = activeIndex !== null ? descriptors[activeIndex] : undefined
const activeIsRotate =
!!activeDescriptor && 'shape' in activeDescriptor && activeDescriptor.shape === 'rotate'
const arrows = descriptors.map((descriptor, index) => {
if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
// A `latch` cube toggles its group's visibility; render it always.
if (descriptor.kind === 'latch') {
return (
@@ -442,6 +454,8 @@ function NodeArrowHandlesForNode({
descriptor={descriptor}
dragControls={dragControls}
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}
liveNode={node}
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') {
// Tap-action handles (fence side-move arrows, corner pickers) aren't
// resize handles, so the freeze-at-pre-drag mechanism — which only
@@ -712,14 +715,18 @@ function LinearArrow({
return {
overrideId,
onBegin: () => {
if (measureLabel) {
useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel })
}
// Always claim the handle-drag scope so the HUD knows a resize is the
// 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: () => {
if (measureLabel) {
useEditor.getState().setActiveHandleDrag(null)
}
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
if (onDrag) useOpeningGuides.getState().clear()
},
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
@@ -1190,8 +1197,23 @@ function ArcArrow({
}
const initialAngle = angleOf(hitWorld)
// Advertise the rotate interaction so the contextual HUD can surface the
// Shift = free-rotation toggle (the angle-step bypass below). Resize
// handles route a measurement label here; rotate gets a sentinel label so
// the HUD shows the rotate hint, not a dimension pill.
if (isRotateShape) {
useInteractionScope
.getState()
.begin({ kind: 'handle-drag', nodeId: node.id, handle: ROTATE_HANDLE_DRAG_LABEL })
}
return {
onEnd: () => setRotationDelta(null),
onEnd: () => {
setRotationDelta(null)
if (isRotateShape) {
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
}
},
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
const hit = new Vector3()
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
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage
// move tools, endpoint drags, or any other editor-state transition without
@@ -2,15 +2,11 @@ import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
type CeilingNode,
type ColumnNode,
createSceneApi,
emitter,
type FenceNode,
type GridEvent,
getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial,
getMaterialPresetByRef,
getRoofSegmentSurfaceY,
getSelectableKinds,
type ItemNode,
@@ -22,11 +18,7 @@ import {
type RoofSegmentEvent,
type RoofSegmentNode,
resolveLevelId,
resolveMaterial,
type ShelfNode,
type SlabNode,
type StairEvent,
type StairNode,
type StairSegmentEvent,
type StairSurfaceMaterialRole,
sceneRegistry,
@@ -35,14 +27,12 @@ import {
} from '@pascal-app/core'
import {
applyMaterialPresetToMaterials,
createMaterial,
createMaterialFromPresetRef,
getRoofMaterialArray,
getStairBodyMaterials,
getStairRailingMaterial,
useViewer,
} from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
import {
@@ -56,11 +46,17 @@ import {
type ActivePaintMaterial,
buildRoofSegmentSurfaceMaterialPatch,
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
hasActivePaintMaterial,
resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint'
import {
availablePaintScopes,
commitPaintScopeFanout,
nodeSlotRoles,
type PaintHoverInfo,
resolvePaintScopeTargets,
slotDisplayLabel,
} from '../../lib/paint-scope'
import {
resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick,
@@ -70,6 +66,12 @@ import {
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
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 { swallowNextClick } from './node-arrow-handles'
@@ -108,6 +110,9 @@ type PaintInteraction = {
hoverMode: HoverHighlightMode
hoveredId: AnyNodeId
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 {
@@ -234,6 +239,28 @@ function getRegisteredMesh(nodeId: string): 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()
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(
node: RoofNode,
role: 'top' | 'edge' | 'wall',
@@ -382,164 +395,6 @@ function applyRoofSegmentPaintPreview(
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
// `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
// paint.ts). The generic registry-driven arm in this file consults
@@ -847,6 +702,10 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
export const SelectionManager = () => {
const phase = useEditor((s) => s.phase)
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 modifierKeysRef = useRef<SelectionModifierKeys>({
meta: false,
@@ -855,9 +714,8 @@ export const SelectionManager = () => {
})
const clickHandledRef = useRef(false)
const movingNode = useEditor((s) => s.movingNode)
const curvingWall = useEditor((s) => s.curvingWall)
const curvingFence = useEditor((s) => s.curvingFence)
const movingNode = useMovingNode()
const isCurveReshape = useIsCurveReshape()
useEffect(() => {
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
@@ -870,9 +728,12 @@ export const SelectionManager = () => {
useEffect(() => {
if (mode !== 'material-paint') return
if (movingNode || curvingWall) return
if (movingNode || isCurveReshape) return
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 = () => {
activePreview?.restore()
@@ -934,13 +795,52 @@ export const SelectionManager = () => {
ray: event.nativeEvent.ray,
})
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 {
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,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
paintHover:
compatible && role
? {
scopes: availablePaintScopes({ node, slotRoles }),
slotLabel: slotDisplayLabel(node, role),
nodeNoun: node.type,
}
: null,
apply:
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 = {
node,
role,
@@ -962,15 +862,33 @@ export const SelectionManager = () => {
preview:
compatible && role
? () => {
const root = getRegisteredNodeObject(node.id)
if (!root) return null
return paintCap.applyPreview({
node,
role,
material: paintSpec.material,
materialPreset: paintSpec.materialPreset,
root,
})
// Preview every surface the click would paint, so room /
// whole-item / all-matching show the full spread, not just the
// hovered surface. Each target is the same kind, so its own
// paint capability builds the preview; restores combine.
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,
materialPreset: paintSpec.materialPreset,
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'),
}
@@ -999,6 +917,16 @@ export const SelectionManager = () => {
}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
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:
compatible && role
? () => {
@@ -1041,77 +969,9 @@ export const SelectionManager = () => {
}
}
if (node.type === 'stair' || node.type === 'stair-segment') {
const stairNode =
node.type === 'stair'
? 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'),
}
}
// Only `roof` / `roof-segment` reach a legacy paint arm (above) — every
// other paintable kind declares `capabilities.paint` and returns from the
// registry-driven dispatch at the top of this function.
const disabledNodeTypes = ['zone']
if (disabledNodeTypes.includes(node.type)) {
@@ -1119,6 +979,7 @@ export const SelectionManager = () => {
key: `${node.type}:${node.id}:unsupported`,
hoveredId: node.id as AnyNodeId,
hoverMode: 'paint-disabled',
paintHover: null,
apply: null,
preview: () => previewCursor('not-allowed'),
}
@@ -1138,6 +999,12 @@ export const SelectionManager = () => {
if (!interaction) return
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) {
return
@@ -1157,6 +1024,10 @@ export const SelectionManager = () => {
const interaction = getPaintInteraction(event)
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) {
return
}
@@ -1224,7 +1095,16 @@ export const SelectionManager = () => {
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 () => {
unsubscribePaintScope()
for (const type of subscribedKinds) {
emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:move` as any, onEnter as any)
@@ -1234,8 +1114,9 @@ export const SelectionManager = () => {
clearActivePreview()
useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default')
useEditor.getState().setPaintHover(null)
}
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
}, [isCurveReshape, mode, movingNode, setHoverHighlightMode])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -1269,7 +1150,7 @@ export const SelectionManager = () => {
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
if (movingNode || isCurveReshape) return
const onPointerDown = (event: NodeEvent) => {
const pointer = pointerEventFromNodeEvent(event)
@@ -1306,7 +1187,7 @@ export const SelectionManager = () => {
swallowNextClick()
createEditorApi().engageMoveDrag(node)
requestAnimationFrame(() => {
if (useEditor.getState().movingNode?.id !== node.id) return
if (getMovingNode()?.id !== node.id) return
pointerTarget?.dispatchEvent(
new PointerEvent('pointermove', {
altKey: moveEvent.altKey,
@@ -1330,7 +1211,7 @@ export const SelectionManager = () => {
if (engaged) {
requestAnimationFrame(() => {
const editor = useEditor.getState()
if (editor.movingNode?.id !== node.id || !editor.placementDragMode) return
if (getMovingNode()?.id !== node.id || !editor.placementDragMode) return
editor.setMovingNode(null)
})
}
@@ -1374,11 +1255,57 @@ export const SelectionManager = () => {
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(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
if (movingNode || isCurveReshape) return
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 2 || !isCommandModifier(event)) return
@@ -1481,16 +1408,26 @@ export const SelectionManager = () => {
return () => {
window.removeEventListener('pointerdown', onPointerDown, true)
}
}, [curvingFence, curvingWall, mode, movingNode])
}, [isCurveReshape, mode, movingNode])
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
if (movingNode || isCurveReshape) return
const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node)
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
// A ceiling is selectable only through its corner handles, never via
@@ -1533,6 +1470,15 @@ export const SelectionManager = () => {
if (activeStrategy?.isValid(node)) {
event.stopPropagation()
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
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
// hole-edit mode. The hole handles + hit mesh stopPropagation, so a
// click reaching here means the user clicked outside the hole.
if (useEditor.getState().editingHole) {
useEditor.getState().setEditingHole(null)
if (getEditingHole()) {
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(
@@ -1636,11 +1601,6 @@ export const SelectionManager = () => {
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
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)
}
}, [curvingFence, curvingWall, mode, movingNode])
}, [isCurveReshape, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
if (movingNode || isCurveReshape) return
const onEnter = (event: NodeEvent) => {
// 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)
})
}
}, [curvingFence, curvingWall, mode, movingNode])
}, [isCurveReshape, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => {
@@ -9,7 +9,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import { type Camera, type Object3D, Vector3 } from 'three'
import { formatLinearMeasurement } from '../../lib/measurements'
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
import useEditor from '../../store/use-editor'
import { useActiveHandleDrag } from '../../store/use-interaction-scope'
type ViewportSize = {
width: number
@@ -37,7 +37,7 @@ export function SiteEdgeLabels() {
const node = state.nodes[firstRoot]
return node?.type === 'site' ? (node as SiteNode) : null
})
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
const activeHandleDrag = useActiveHandleDrag()
const unit = useViewer((state) => state.unit)
const cameraMode = useViewer((state) => state.cameraMode)
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
@@ -25,7 +25,9 @@ import {
} from 'three'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import { holeEditScope } from '../../lib/interaction/scope'
import useEditor from '../../store/use-editor'
import useInteractionScope, { useEditingHole } from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
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 override = useLiveNodeOverrides((state) => state.overrides.get(slabId))
const hoveredHole = useEditor((state) => state.hoveredHole)
const editingHole = useEditor((state) => state.editingHole)
const editingHole = useEditingHole()
const setHoveredHole = useEditor((state) => state.setHoveredHole)
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 —
// manual holes and holes that predate holeMetadata — opens the editor.
if (metadata?.source === 'stair' && metadata.stairId) {
useEditor.getState().setEditingHole(null)
useInteractionScope
.getState()
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
useEditor.getState().setHoveredHole(null)
resetPointerCursor()
selectOwnedNode(metadata.stairId, 'stair')
return
}
if (metadata?.source === 'elevator' && metadata.elevatorId) {
useEditor.getState().setEditingHole(null)
useInteractionScope
.getState()
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
useEditor.getState().setHoveredHole(null)
resetPointerCursor()
selectOwnedNode(metadata.elevatorId, 'elevator')
return
}
useEditor.getState().setEditingHole({ nodeId: slabId, holeIndex })
useInteractionScope.getState().begin(holeEditScope({ nodeId: slabId, holeIndex }))
useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] })
},
[holeIndex, metadata, slabId],
@@ -6,10 +6,11 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import usePlacementPreview from '../../store/use-placement-preview'
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting'
type UseFloorplanBackgroundPlacementArgs = {
activePolygonDraftPoints: WallPlanPoint[]
@@ -42,7 +43,7 @@ type UseFloorplanBackgroundPlacementArgs = {
) => boolean
handleCeilingPlacementPoint: (point: WallPlanPoint) => void
handleSlabPlacementPoint: (point: WallPlanPoint) => void
handleWallPlacementPoint: (point: WallPlanPoint, options?: { singleWall?: boolean }) => void
handleWallPlacementPoint: (point: WallPlanPoint) => void
handleZonePlacementPoint: (point: WallPlanPoint) => void
isCeilingBuildActive: boolean
isCeilingItemPlacementActive: boolean
@@ -61,7 +62,6 @@ type UseFloorplanBackgroundPlacementArgs = {
setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
shiftPressed: boolean
snapWallDraftPoint: (args: {
point: WallPlanPoint
walls: WallNode[]
@@ -75,7 +75,6 @@ type UseFloorplanBackgroundPlacementArgs = {
point: WallPlanPoint
start?: WallPlanPoint
angleSnap: boolean
bypassSnap?: boolean
}) => WallPlanPoint
toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
walls: WallNode[]
@@ -122,7 +121,6 @@ export function useFloorplanBackgroundPlacement({
setFenceDraftStart,
setRoofDraftEnd,
setRoofDraftStart,
shiftPressed,
snapWallDraftPoint,
snapPolygonDraftPoint,
toPoint2D,
@@ -160,24 +158,21 @@ export function useFloorplanBackgroundPlacement({
}
if (isCeilingBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Align the committed vertex the same way the move-preview did, so
// the placed point matches what the user saw. Wall magnetic snap may
// still win; generic alignment is skipped when angle snap owns the
// vertex (matches the move branch).
const angleSnap = ceilingDraftPoints.length > 0 && !bypassSnap
// Align the committed vertex the same way the move-preview did, so the
// placed point matches what the user saw — mode-driven (the chip):
// `grid` quantizes, `angles` locks 15° rays, `lines` snaps onto walls /
// alignment, `off` is free. Alt forces (skips alignment).
const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive()
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap,
bypassSnap,
})
const snappedPoint = resolveCeilingPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap,
}).point
@@ -187,11 +182,11 @@ export function useFloorplanBackgroundPlacement({
}
if (isRoofBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
const snappedPoint = alignFloorplanDraftPoint(
bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint),
{ bypass: event.altKey || bypassSnap },
)
// Footprint placement (polygon context: grid / lines / off, no angle),
// mode-driven to match the chip. Alt forces (skips alignment).
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
bypass: event.altKey || !isMagneticSnapActive(),
})
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
@@ -205,32 +200,30 @@ export function useFloorplanBackgroundPlacement({
}
if (isFenceBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
// Figma alignment — endpoint snap wins (same precedence as move).
// While a draft is open the segment locks to 15° rays from its
// start unless Shift is held; Shift bypasses grid, magnetic,
// angle, and alignment snap. `gridSnap` keeps the regular snap
// on the world XZ grid even when the building is rotated.
const fenceStep = WALL_GRID_STEP
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
// Fence draft: mode-driven (matches the chip), same as the move
// preview. `grid` snaps to the world XZ grid (rotation-safe via the
// `gridSnap` callback), `angles` locks 15° rays from the start, `lines`
// pulls onto walls / fences / alignment, `off` is free.
const fenceStep = getSegmentGridStep()
const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
const fenceSnapped = snapFenceDraftPoint({
point: planPoint,
walls,
fences,
start: fenceDraftStart ?? undefined,
angleSnap: fenceAngleSnap,
bypassSnap,
magnetic: isMagneticSnapActive(),
gridSnap: (p) => worldGridSnap(p, fenceStep),
})
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep)
const fenceGridBase = worldGridSnap(planPoint, fenceStep)
const fenceLocked =
!bypassSnap &&
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
const snappedPoint =
fenceLocked || fenceAngleSnap
? fenceSnapped
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap })
: alignFloorplanDraftPoint(fenceSnapped, {
bypass: !isMagneticSnapActive(),
})
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
@@ -249,6 +242,14 @@ export function useFloorplanBackgroundPlacement({
} else if (
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
// committed fence's resolved end — chain the 2D draft from the
// 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
// the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
const angleSnap = activePolygonDraftPoints.length > 0 && !bypassSnap
const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive()
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap,
bypassSnap,
})
let snappedPoint = fallbackPoint
if (isSlabBuildActive) {
@@ -283,12 +282,11 @@ export function useFloorplanBackgroundPlacement({
fallbackPoint,
levelId,
altKey: event.altKey,
shiftKey: bypassSnap,
align: !angleSnap,
}).point
} else if (!angleSnap) {
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
// the dashed-line draft preview invisible.
if (isWallBuildActive) {
const bypassSnap = shiftPressed || event.shiftKey
// Wall draft: grid snap (+ existing-wall endpoint/join snap), then
// Figma alignment — endpoint/join snap wins (same precedence as the
// move-preview branch), so committing onto a corner still works.
// While a draft is open the segment locks to 15° rays from its
// start unless Shift is held; Shift bypasses grid, magnetic,
// angle, and alignment snap. `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
// Wall draft: mode-driven (matches the chip + the move-preview branch).
// `grid` snaps to the world XZ grid (rotation-safe via `gridSnap`),
// `angles` locks 15° rays from the start, `lines` pulls the endpoint
// onto existing wall corners / edges + alignment, `off` is free.
const wallStep = getSegmentGridStep()
const wallAngleSnap = draftStart !== null && isAngleSnapActive()
const wallSnapped = snapWallDraftPoint({
point: planPoint,
walls,
start: draftStart ?? undefined,
angleSnap: wallAngleSnap,
bypassSnap,
gridSnap: (p) => worldGridSnap(p, wallStep),
})
const wallGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, wallStep)
const wallLocked =
!bypassSnap && (wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1])
const wallGridBase = worldGridSnap(planPoint, wallStep)
const wallLocked = wallSnapped[0] !== wallGridBase[0] || wallSnapped[1] !== wallGridBase[1]
let snappedPoint = wallSnapped
if (wallLocked) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
applySnap: !wallAngleSnap,
bypass: event.altKey || bypassSnap,
// Figma alignment pulls the endpoint onto existing wall corners /
// edges, so it is a line snap — suppress it whenever magnetic snap
// is off (`'off'` / `'angles'`), matching the wall-geometry snap.
bypass: !isMagneticSnapActive(),
})
}
@@ -356,7 +351,7 @@ export function useFloorplanBackgroundPlacement({
return true
}
handleWallPlacementPoint(snappedPoint, { singleWall: event.altKey })
handleWallPlacementPoint(snappedPoint)
return true
}
@@ -414,7 +409,6 @@ export function useFloorplanBackgroundPlacement({
setFenceDraftStart,
setRoofDraftEnd,
setRoofDraftStart,
shiftPressed,
snapWallDraftPoint,
snapPolygonDraftPoint,
toPoint2D,
@@ -32,8 +32,14 @@ import {
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { endpointReshapeScope } from '../../lib/interaction/scope'
import { sfxEmitter } from '../../lib/sfx-bus'
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 {
createArrowHitAreaGeometry,
@@ -118,11 +124,9 @@ export function WallMoveSideHandles() {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const movingNode = useMovingNode()
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
// Fence side-move / height / corner-pickers now flow through the
@@ -140,10 +144,8 @@ export function WallMoveSideHandles() {
!isFloorplanHovered &&
mode !== 'delete' &&
!movingNode &&
!movingWallEndpoint &&
!movingFenceEndpoint &&
!curvingWall &&
!curvingFence
!endpointReshape &&
!isCurveReshape
if (!shouldRender || !selectedNode) return null
@@ -333,7 +335,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick')
document.body.style.cursor = 'grabbing'
useEditor.getState().setMovingWallEndpoint({ wall, endpoint })
useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint))
}
return (
@@ -468,7 +470,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
document.body.style.cursor = 'ns-resize'
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
// synthesized click doesn't reroute selection to whatever mesh sits
// under the cursor at release.
@@ -498,7 +500,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
document.body.style.cursor = ''
}
useScene.temporal.getState().resume()
useEditor.getState().setActiveHandleDrag(null)
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
useViewer.getState().setInputDragging(false)
dragCleanupRef.current = null
}
@@ -611,10 +613,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(wall)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
// Keep the wall selected so it stays the active item once the move
// 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')
useEditor.getState().setMovingNode(fence)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
// Keep the fence selected so it stays active once the move commits.
}
@@ -19,6 +19,10 @@ import {
} from '../../../lib/ceiling-plan-snap'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import useInteractionScope, {
useIsCurveReshape,
useMovingNode,
} from '../../../store/use-interaction-scope'
import { snapToHalf } from '../../tools/item/placement-math'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
@@ -95,8 +99,8 @@ export const CeilingSelectionAffordanceSystem = () => {
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const structureLayer = useEditor((state) => state.structureLayer)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const movingNode = useMovingNode()
const isCurveReshape = useIsCurveReshape()
const currentLevelId = useViewer((state) => state.selection.levelId)
const ceilings = useScene(
@@ -117,7 +121,7 @@ export const CeilingSelectionAffordanceSystem = () => {
mode === 'select' &&
structureLayer === 'elements' &&
!movingNode &&
!curvingWall &&
!isCurveReshape &&
currentLevelId !== null
if (!shouldRender) return null
@@ -196,9 +200,11 @@ const CeilingSelectionAffordance = ({
const selectCeilingForEdit = useCallback(() => {
const editor = useEditor.getState()
editor.setMovingNode(null)
editor.setMovingWallEndpoint(null)
editor.setCurvingWall(null)
editor.setEditingHole(null)
useInteractionScope
.getState()
.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')
useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] })
}, [effectiveCeiling.id])
@@ -483,9 +489,11 @@ const CornerBracket = ({
e.stopPropagation()
useEditor.getState().setMovingNode(null)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setEditingHole(null)
useInteractionScope
.getState()
.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')
emitter.emit('ceiling:click' as any, {
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { Color, type Material, type Mesh } from 'three'
import useEditor from '../../../store/use-editor'
import { useMovingNode } from '../../../store/use-interaction-scope'
const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff'
const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial'
@@ -75,7 +76,7 @@ function setCeilingGridHighlighted(ceilingGrid: Mesh, highlighted: boolean) {
export const CeilingSystem = () => {
const tool = useEditor((state) => state.tool)
const selectedItem = useEditor((state) => state.selectedItem)
const movingNode = useEditor((state) => state.movingNode)
const movingNode = useMovingNode()
const selectedIds = useViewer((state) => state.selection.selectedIds)
const activeLevelId = useViewer((state) => state.selection.levelId)
const hoveredId = useViewer((state) => state.hoveredId)
@@ -6,8 +6,10 @@ import { Check, Pencil } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
// ─── Per-zone label editor ────────────────────────────────────────────────────
@@ -19,6 +21,10 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
const selectedZoneId = useViewer((s) => s.selection.zoneId)
const hoveredId = useViewer((s) => s.hoveredId)
const mode = useEditor((s) => s.mode)
// During an active interaction the zone label is a context badge that steps
// back: faded + non-interactive so it can't be hovered/clicked mid-action.
const scope = useInteractionScope((s) => s.scope)
const labelStepBack = resolveOverlayPolicy(scope).contextBadges === 'faded'
const isSelected = selectedZoneId === zoneId
const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
const [editing, setEditing] = useState(false)
@@ -149,7 +155,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
fontSize: 14,
fontFamily: 'sans-serif',
userSelect: 'none',
pointerEvents: 'auto',
pointerEvents: labelStepBack ? 'none' : 'auto',
opacity: labelStepBack ? 0.4 : undefined,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
@@ -3,7 +3,9 @@ import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { type Group, MathUtils, type Mesh } from 'three'
import type { MeshBasicNodeMaterial } from 'three/webgpu'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
// Disable raycasting on zone geometry so clicks pass through to items underneath.
// Zone selection in the editor is handled exclusively via the HTML label overlay.
@@ -20,6 +22,11 @@ export const ZoneSystem = () => {
// geometry or the HTML zone tags in the framed shot.
const isCaptureMode = useEditor.getState().isCaptureMode
// During any active interaction zone labels step back entirely — they are
// not a primary editing concern and would distract / invite misclicks.
const zoneLabelsHidden =
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
const zoneGeometryVisible = structureLayer === 'zones'
const zones = sceneRegistry.byType.zone || new Set()
const nodes = useScene.getState().nodes
@@ -84,7 +91,8 @@ export const ZoneSystem = () => {
// Labels: visible on the current level (regardless of mode), but never
// during snapshot capture.
const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel
const showLabel =
!isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
const labelOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== labelOpacity) {
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
import usePlacementPreview from '../../../store/use-placement-preview'
import { CursorSphere } from '../shared/cursor-sphere'
import {
@@ -163,7 +164,8 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
// 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
// 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 = (
gridX: number,
gridZ: number,
@@ -195,13 +197,19 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
}
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(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
isGridSnapActive()
? 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[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
const supportY = resolveElevatorSupportY({
buildingId: currentBuildingId,
@@ -221,7 +229,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
})
if (
!bypassSnap &&
(isGridSnapActive() || isMagneticSnapActive()) &&
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
@@ -239,13 +247,17 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
})
if (!latestBuildingId) return
const bypassSnap = event.nativeEvent?.shiftKey === true
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
isGridSnapActive()
? 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[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
commitElevatorPlacement(
latestBuildingId,
@@ -1,7 +1,7 @@
import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
import { nodeRegistry } from '@pascal-app/core'
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 { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
@@ -27,7 +27,7 @@ export const MoveTool: React.FC<{
onNodeMoved?: (nodeId: AnyNodeId) => void
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onNodeMoved }) => {
const movingNode = useEditor((state) => state.movingNode)
const movingNode = useMovingNode()
if (!movingNode) return null
@@ -1,9 +1,26 @@
import { type AssetInput, isObject } from '@pascal-app/core'
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 {
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 {
@@ -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.
*/
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
if (step <= 0) return position
const halfDim = dimension / 2
const offset = positiveModulo(halfDim, step)
return Math.round((position - offset) / step) * step + offset
@@ -23,6 +41,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
* Snap a value to the active grid step (used for wall-local positions).
*/
export function snapToHalf(value: number, step = getGridSnapStep()): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
@@ -30,6 +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`.
*/
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
if (step <= 0) return value
return Math.max(step, Math.ceil(value / step) * step)
}
@@ -16,6 +16,7 @@ import type {
WallNode,
} from '@pascal-app/core'
import {
canHostOnTop,
clampRectToRoofWallFace,
getRoofSegmentWallFace,
getScaledDimensions,
@@ -64,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
}
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
if (!canHostOnTop(surfaceItem)) return null
if (isLowProfileItemSurface(surfaceItem)) return null
if (!isUpwardItemSurfaceHit(event)) return null
@@ -113,14 +115,13 @@ export const floorStrategy = {
// is rotated; then project the world point back into building-local
// for storage. Without this, a rotated building drags placement off
// the world grid.
const bypassSnap = event.nativeEvent?.shiftKey === true
const [x, z] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
snapToGrid(event.position[0], swapDims ? dimZ : dimX),
snapToGrid(event.position[2], swapDims ? dimX : dimZ),
0,
).local
// Snapping is governed by the active mode (snapToGrid returns raw in Off /
// non-grid modes); Alt is force-place only and never bypasses snapping here.
const [x, z] = snapWorldXZForActiveBuilding(
snapToGrid(event.position[0], swapDims ? dimZ : dimX),
snapToGrid(event.position[2], swapDims ? dimX : dimZ),
0,
).local
const y = ctx.gridPosition.y
return {
@@ -202,10 +203,9 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const bypassSnap = event.nativeEvent?.shiftKey === true
const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
const x = snapToHalf(event.localPosition[0])
const y = snapToHalf(event.localPosition[1])
const z = snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator
const rawDims = ctx.draftItem
@@ -237,13 +237,11 @@ export const wallStrategy = {
},
cursorRotationY: cursorRotation,
gridPosition: [x, adjustedY, z],
cursorPosition: bypassSnap
? [event.position[0], event.position[1], event.position[2]]
: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
cursorPosition: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
stopPropagation: true,
}
},
@@ -266,10 +264,9 @@ export const wallStrategy = {
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const bypassSnap = event.nativeEvent?.shiftKey === true
const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
const snappedX = snapToHalf(event.localPosition[0])
const snappedY = snapToHalf(event.localPosition[1])
const snappedZ = snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall(
@@ -287,13 +284,11 @@ export const wallStrategy = {
return {
gridPosition: [snappedX, adjustedY, snappedZ],
cursorPosition: bypassSnap
? [event.position[0], event.position[1], event.position[2]]
: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
cursorPosition: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
cursorRotationY: cursorRotation,
nodeUpdate: {
position: [snappedX, adjustedY, snappedZ],
@@ -393,14 +388,14 @@ type RoofWallTarget = {
* `wall-side` items mount on the outer surface, `wall` items center in
* the wall thickness.
*
* `shiftFree` mirrors the wall flow's Shift override (stubbed
* `freePlace` mirrors the wall flow's Alt override (stubbed
* validators): the profile clamp is skipped, so the rect may overhang
* the face edges — placement follows the snapped cursor as-is.
*/
function resolveRoofWallTarget(
ctx: PlacementContext,
event: RoofEvent,
shiftFree = false,
freePlace = false,
): RoofWallTarget | null {
const attachTo = ctx.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
@@ -414,10 +409,12 @@ function resolveRoofWallTarget(
const dims = getGridAlignedDimensions(rawDims, attachTo)
const [width, height] = dims
const u = shiftFree ? hit.u : snapToHalf(hit.u)
const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
if (!fitted && !shiftFree) return null
// Snap follows the active mode (snapToHalf returns raw in Off/non-grid);
// `freePlace` (Alt) is force-place — it only skips the face-fit validity gate.
const u = snapToHalf(hit.u)
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 finalV = fitted?.v ?? centerV
@@ -483,8 +480,8 @@ export const roofWallStrategy = {
* face. Returns null when the item doesn't wall-attach or the pointer
* isn't over a placeable face.
*/
enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null {
const target = resolveRoofWallTarget(ctx, event, shiftFree)
enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null {
const target = resolveRoofWallTarget(ctx, event, freePlace)
if (!target) return null
return {
@@ -511,11 +508,11 @@ export const roofWallStrategy = {
* segment transitions inside one roof never re-fire roof:enter) or to
* no placeable face.
*/
move(ctx: PlacementContext, event: RoofEvent, shiftFree = false): PlacementResult | null {
move(ctx: PlacementContext, event: RoofEvent, freePlace = false): PlacementResult | null {
if (ctx.state.surface !== 'roof-wall') return null
if (!ctx.draftItem) return null
const target = resolveRoofWallTarget(ctx, event, shiftFree)
const target = resolveRoofWallTarget(ctx, event, freePlace)
if (!target) return null
if (target.segment.id !== ctx.state.roofSegmentId) return null
@@ -538,12 +535,12 @@ export const roofWallStrategy = {
/**
* Handle roof:click — commit placement on the segment wall face.
*/
click(ctx: PlacementContext, _event: RoofEvent, shiftFree = false): CommitResult | null {
click(ctx: PlacementContext, _event: RoofEvent, freePlace = false): CommitResult | null {
if (ctx.state.surface !== 'roof-wall') return null
if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
// Shift mirrors the wall flow's stubbed validators: skip profile-fit
// Alt mirrors the wall flow's stubbed validators: skip profile-fit
// and overlap checks entirely.
if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null
if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
return {
nodeUpdate: {
@@ -615,13 +612,8 @@ export const ceilingStrategy = {
// Ceiling items are stored in ceiling-local coordinates, so snapping must
// use the ceiling hit's local position rather than world position.
const bypassSnap = event.nativeEvent?.shiftKey === true
const x = bypassSnap
? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = bypassSnap
? event.localPosition[2]
: snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
// Recessed fixtures seat flush with the ceiling plane (body rising into the
// void above); everything else hangs its full height below the ceiling.
const seatY = ctx.asset.recessed ? 0 : -itemHeight
@@ -654,13 +646,8 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const bypassSnap = event.nativeEvent?.shiftKey === true
const x = bypassSnap
? event.localPosition[0]
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = bypassSnap
? event.localPosition[2]
: snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
// Recessed fixtures seat flush with the ceiling plane (body rising into the
// void above); everything else hangs its full height below the ceiling.
const seatY = ctx.draftItem.asset.recessed ? 0 : -itemHeight
@@ -771,9 +758,8 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -823,9 +809,8 @@ export const itemSurfaceStrategy = {
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -924,9 +909,8 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
@@ -969,9 +953,8 @@ export const shelfSurfaceStrategy = {
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
if (rowY === null) return null
const bypassSnap = event.nativeEvent?.shiftKey === true
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
return {
@@ -40,11 +40,16 @@ import {
} from 'three'
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import {
clearPlacementSurface,
publishPlacementSurface,
} from '../../../lib/active-placement-surface'
import { EDITOR_LAYER } from '../../../lib/constants'
import { formatLinearMeasurement } from '../../../lib/measurements'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
import useEditor from '../../../store/use-editor'
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import {
createLineGeometry,
@@ -56,7 +61,9 @@ import {
getDetachedAttachmentPreviewLift,
getGridAlignedDimensions,
snapToGrid,
snapToHalf,
snapUpToGridStep,
steppedRotation,
} from './placement-math'
import {
ceilingStrategy,
@@ -76,6 +83,13 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
* floor-plan overlay and the 3D registry move tool. */
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.
* 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,
},
)
const shiftFreeRef = useRef(false)
const altFreeRef = useRef(false)
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.
// The per-frame mesh-position lerp below is only useful for that path;
// when the move is being driven externally (2D `FloorplanRegistryMoveOverlay`
@@ -441,7 +463,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
})
const getActiveValidators = () =>
shiftFreeRef.current
altFreeRef.current
? {
canPlaceOnFloor: () => ({ valid: true }),
canPlaceOnWall: () => ({ valid: true }),
@@ -449,8 +471,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
: 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 placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
edgeMaterial.color.setHex(color)
basePlaneMaterial.color.setHex(color)
@@ -608,10 +654,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
// Floor grab-offset: the item tracks the grabbed point instead of snapping
// its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid
// (`event.position`) on its default path and only reads `event.localPosition`
// under Shift, so both frames must carry the offset; the world point is
// derived from the corrected local one so the two stay consistent.
// its origin under the cursor. The offset is computed in building-local space
// (`event.localPosition`), but `floorStrategy.move` snaps on the WORLD grid
// (`event.position`), so the corrected local point is re-projected to a
// corrected world point and both frames carry the offset to stay consistent.
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
if (relativeFloorStart === null) return event
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
// nearest real corner of the candidate (resolver tie-break), so the dot
// always sits on an actual point. The delta is applied to BOTH the grid
// and cursor positions below. Alt bypasses alignment; Shift bypasses all snap.
// and cursor positions below. Alt 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
let alignX = 0
let alignZ = 0
const bypassSnap = floorEvent.nativeEvent?.shiftKey === true
const bypassAlign = floorEvent.nativeEvent?.altKey === true || bypassSnap
// Alignment ("lines") follows the snapping mode only — Alt is force-place,
// it does NOT bypass snapping (Off mode is the no-snap bypass).
const bypassAlign = !isMagneticSnapActive()
if (!bypassAlign && draft) {
alignmentCandidates ??= collectAlignmentAnchors(
useScene.getState().nodes,
@@ -812,7 +862,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Play snap sound when grid position changes
if (
!bypassSnap &&
previousGridPos &&
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
) {
@@ -864,8 +913,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
draftNode.create(
gridPosition.current,
asset,
@@ -881,7 +932,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds)
revalidate()
}
})
}
// ---- Wall Handlers ----
@@ -997,7 +1048,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.z !== result.gridPosition[2]
// Play snap sound when grid position changes
if (event.nativeEvent?.shiftKey !== true && posChanged) {
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -1060,12 +1111,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) {
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) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
}
if (configRef.current.onCommitted()) {
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(
getContext(),
@@ -1079,7 +1132,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else {
revalidate()
}
}
})
}
const onWallLeave = (event: WallEvent) => {
@@ -1121,7 +1174,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// re-enters whenever the strategy reports a segment change.
const enterRoofWall = (event: RoofEvent): boolean => {
const result = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
if (!result) return false
event.stopPropagation()
@@ -1152,7 +1205,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return
}
const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current)
const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
if (!result) {
// Different segment under the pointer (or no placeable face) —
// try a fresh enter; a null resolve leaves the draft where it is.
@@ -1167,7 +1220,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2]
if (!shiftFreeRef.current && posChanged) {
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -1210,23 +1263,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onRoofWallClick = (event: RoofEvent) => {
const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current)
const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
if (!result) return
event.stopPropagation()
if (draftNode.current) {
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()) {
const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
})
}
const onRoofWallLeave = (event: RoofEvent) => {
@@ -1261,9 +1316,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.position[1],
event.position[2],
)
const bypassSnap = event.nativeEvent?.shiftKey === true
const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2
const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2
// Mode-aware snap (raw in Off / non-grid); Alt is force-place, not bypass.
const wx = snapToHalf(buildingLocalPoint.x)
const wz = snapToHalf(buildingLocalPoint.z)
const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, {
@@ -1426,15 +1481,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
})
return
}
}
@@ -1452,15 +1509,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
})
return
}
}
@@ -1481,8 +1540,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const committedId = draftNode.current?.id ?? null
const wasAdopted = draftNode.isAdopted
const finalId = draftNode.commit(result.nodeUpdate)
finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => {
const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter(
getContext(),
@@ -1495,7 +1556,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else {
revalidate()
}
}
})
return
}
}
@@ -1511,9 +1572,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) {
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
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
if (enterResult) {
@@ -1521,7 +1584,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else {
revalidate()
}
}
})
}
// ---- Ceiling Handlers ----
@@ -1598,7 +1661,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2]
if (event.nativeEvent?.shiftKey !== true && posChanged) {
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -1637,9 +1700,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) {
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 enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (enterResult) {
@@ -1647,7 +1712,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} else {
revalidate()
}
}
})
}
const onCeilingLeave = (event: CeilingEvent) => {
@@ -1775,26 +1840,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) {
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)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
})
}
// ---- 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) => {
if (event.key === 'Shift') {
shiftFreeRef.current = true
if (event.key === 'Alt') {
altFreeRef.current = true
revalidate()
return
}
@@ -1811,17 +1875,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// manual rotation would skew them off the wall plane.
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)
rotationDelta = ROTATION_STEP
rotationDir = 1
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()
sfxEmitter.emit('sfx:item-rotate')
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]]
// Ref + cursor mesh + item mesh — no store update during drag
@@ -1908,8 +1973,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftFreeRef.current = false
if (event.key === 'Alt') {
altFreeRef.current = false
revalidate()
}
}
@@ -1926,13 +1991,35 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
emitter.on('tool:cancel', onCancel)
// ---- Right-click cancel ----
const onContextMenu = (event: MouseEvent) => {
if (configRef.current.onCancel) {
event.preventDefault()
configRef.current.onCancel()
// ---- 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) => {
if (configRef.current.onCancel) event.preventDefault()
}
window.addEventListener('pointerdown', onRightPointerDown, true)
window.addEventListener('pointerup', onRightPointerUp, true)
window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ----
@@ -1997,6 +2084,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('shelf:move', onShelfMove)
emitter.on('shelf:click', onShelfClick)
emitter.on('shelf:leave', onShelfLeave)
// A floor placement commits at the tracked floor cursor (`gridPosition`),
// which keeps following the floor even when the click ray lands on a wall
// (grid:move uses a separate ground-plane raycast). Without this, a commit
// click whose ray hits a wall fires only `wall:click` — whose handler
// declines for a floor item — and the click is silently eaten (the user
// has to click again until the ray happens to clear the wall). Route every
// surface click to the floor commit too; `floorStrategy.click` guards on
// `surface === 'floor'` (and a non-attach draft), so it no-ops while the
// draft is actually resting on that surface.
const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
if (placementState.current.surface !== 'floor') return
onGridClick(event as unknown as GridEvent)
}
emitter.on('wall:click', commitFloorOnSurfaceClick as never)
emitter.on('item:click', commitFloorOnSurfaceClick as never)
emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
emitter.on('roof:click', commitFloorOnSurfaceClick as never)
emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
return () => {
@@ -2032,9 +2138,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('shelf:move', onShelfMove)
emitter.off('shelf:click', onShelfClick)
emitter.off('shelf:leave', onShelfLeave)
emitter.off('wall:click', commitFloorOnSurfaceClick as never)
emitter.off('item:click', commitFloorOnSurfaceClick as never)
emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
emitter.off('roof:click', commitFloorOnSurfaceClick as never)
emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('pointerdown', onRightPointerDown, true)
window.removeEventListener('pointerup', onRightPointerUp, true)
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).
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) {
reconcileDraftRaycast(null)
return
@@ -2145,12 +2325,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
mesh.visible = true
if (placementState.current.surface === 'floor') {
const distance = mesh.position.distanceToSquared(gridPosition.current)
if (distance > 1) {
mesh.position.copy(gridPosition.current)
} else {
mesh.position.lerp(gridPosition.current, delta * 20)
}
// Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
// active move visibly trail the cursor and — combined with React
// re-renders momentarily pulling the mesh back toward its committed
// position — read as a laggy snap-back on every move. Copying each frame
// locks placement/move to the cursor and overrides any stray reset
// within a single frame, so it feels precise instead of dragging.
mesh.position.copy(gridPosition.current)
// Adjust Y for slab elevation (floor items on top of slabs)
if (!asset.attachTo) {
@@ -2199,6 +2380,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
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 depthLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[2], unit)
const heightLabel = formatLinearMeasurement(currentDimensionBounds.dimensions[1], unit)
@@ -2217,7 +2404,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
currentDimensionBounds.dimensions[1] / 2,
currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
]
const measurementContent = (
<>
<lineSegments
@@ -16,6 +16,7 @@ import {
type PortConnectivity,
resolveAlignment,
resolveConnectivityUpdates,
resolveFacingIndicator,
sceneRegistry,
spatialGridManager,
useLiveNodeOverrides,
@@ -30,7 +31,9 @@ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
import useFacingPose from '../../../store/use-facing-pose'
import { swallowNextClick } from '../../editor/node-arrow-handles'
import { CursorSphere } from '../shared/cursor-sphere'
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
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
const snapToGridStep = (value: number) => {
if (!resolveSnapFlags(getActiveSnappingMode()).grid) return value
const step = useEditor.getState().gridSnapStep
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.
const overriddenIdsRef = useRef<AnyNodeId[]>([])
// Shelf placement shows the same green/red footprint box GLB items use
// (instead of the vertical-arrow cursor) and refuses an invalid drop unless
// Shift forces it. The footprint comes from the kind's `floorPlaced`
// capability so this stays generic if we ever opt other kinds in.
const isShelf = node.type === 'shelf'
// Colliding floor kinds (item / shelf / column) show the same green/red
// footprint box GLB items use (instead of the vertical-arrow cursor) and
// refuse an invalid drop unless Alt forces it. The gate + footprint both come
// from the kind's declarative `floorPlaced` capability, so opting a new kind
// in is just `collides: true` — no change here.
const collides = nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
const boxDimensions = useMemo(
() =>
isShelf
collides
? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
null)
: null,
[isShelf, node],
[collides, node],
)
const [valid, setValid] = useState(true)
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
// the core registry, so it stays layer-clean (no @pascal-app/nodes import).
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
// Mirrors of `valid` / Shift for the event handlers inside the effect, which
// Mirrors of `valid` / Alt for the event handlers inside the effect, which
// can't read React state without stale closures.
const validRef = useRef(true)
const shiftRef = useRef(false)
const altRef = useRef(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
@@ -259,7 +264,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
dragAnchorRef.current = null
hasMovedRef.current = false
rotationRef.current = originalRotationY
shiftRef.current = false
altRef.current = false
validRef.current = true
// Re-sync the box transform to the (possibly new) node. `node` changes
// without this component remounting whenever a positioned preset re-arms a
@@ -335,12 +340,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
// Re-run the floor-collision check at the live cursor + rotation and push
// the result to the box colour. Shift forces a valid (green) override so
// the user can drop on top of an existing item on purpose. Only shelves
// show the box, so this no-ops for every other movable kind.
// the result to the box colour. Alt (free place) forces a valid (green)
// override so the user can drop on top of an existing item on purpose. Only
// shelves show the box, so this no-ops for every other movable kind.
const recomputeValidity = () => {
if (!boxDimensions) return
if (shiftRef.current) {
if (altRef.current) {
validRef.current = true
setValid(true)
return
@@ -417,7 +422,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
// Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
snap: snapToGridStep,
})
dragAnchorRef.current = resolved.anchor
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,
// snap and publish a guide. The guide connects to the nearest real
// corner of the candidate (resolver tie-break), so the dot always sits
// on an actual point. Alt bypasses alignment; Shift bypasses all snap.
const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true
// on an actual point. Alignment ("lines") follows the snapping mode only —
// Alt is force-place (forces a valid drop), it does not bypass snapping.
const bypass = !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
@@ -488,7 +495,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
previewConnectivity(position, rotationRef.current)
const prev = previousSnapRef.current
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) {
if (!prev || prev[0] !== x || prev[1] !== z) {
sfxEmitter.emit('sfx:grid-snap')
previousSnapRef.current = [x, z]
}
@@ -524,9 +531,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// deliberate drop. Prevents preset re-arm from double-placing.
if (!hasMovedRef.current) return
// Refuse a drop on an invalid (red) footprint, matching the GLB item
// tool — unless Shift is held to force placement. Other kinds carry no
// validity box (`validRef` stays true), so they're never blocked.
if (!validRef.current && !shiftRef.current) return
// tool — unless Alt (free place) is held to force placement. Other kinds
// carry no validity box (`validRef` stays true), so they're never blocked.
if (!validRef.current && !altRef.current) return
const position: [number, number, number] = [...lastCursorRef.current]
const rotation = toCommitRotation(rotationRef.current)
@@ -624,10 +631,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// item placement keys (and the "Rotate" hints the move HUD shows). Applied
// imperatively + mirrored to the live transform; committed on drop.
const onKeyDown = (e: KeyboardEvent) => {
// Hold Shift to force placement on an invalid (red) footprint, matching
// the GLB item tool. Recolour the box to green while held.
if (e.key === 'Shift') {
shiftRef.current = true
// Hold Alt (free place) to force placement on an invalid (red) footprint,
// matching the GLB item tool. Recolour the box to green while held.
if (e.key === 'Alt') {
altRef.current = true
recomputeValidity()
return
}
@@ -659,8 +666,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
recomputeValidity()
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftRef.current = false
if (e.key === 'Alt') {
altRef.current = false
recomputeValidity()
}
}
@@ -768,6 +775,23 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
[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 (boxDimensions) {
@@ -23,7 +23,7 @@ import {
resolveAlignmentForActiveBuilding,
snapWorldXZForActiveBuilding,
} 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'
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
// 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
// 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 = (
gridX: number,
gridZ: number,
@@ -241,21 +242,22 @@ export const RoofTool: React.FC = () => {
if (!cursorRef.current) return
// World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid.
const bypassSnap = event.nativeEvent?.shiftKey === true
const snapped: [number, number] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
// used to drag every roof corner off the visible grid. Snapping follows
// the global mode (grid quantize / lines alignment); Off keeps the raw
// cursor. Shift cycles the mode centrally — this tool never reads it.
const snapped: [number, number] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint(
snapped[0],
snapped[1],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
const y = event.localPosition[1]
@@ -265,7 +267,7 @@ export const RoofTool: React.FC = () => {
cursorRef.current.position.set(gridX, gridY, gridZ)
if (
!bypassSnap &&
(isGridSnapActive() || isMagneticSnapActive()) &&
corner1Ref.current &&
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
@@ -290,21 +292,21 @@ export const RoofTool: React.FC = () => {
if (!currentLevelId) return
// World-grid snap projected into building-local; rotated buildings
// used to drag every roof corner off the visible grid.
const bypassSnap = event.nativeEvent?.shiftKey === true
const snapped: [number, number] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
// used to drag every roof corner off the visible grid. Snapping follows
// the global mode; Off keeps the raw cursor.
const snapped: [number, number] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
const [gridX, gridZ] = alignPoint(
snapped[0],
snapped[1],
event.localPosition[0],
event.localPosition[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
const y = event.localPosition[1]
@@ -4,6 +4,7 @@ import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import { Box3, type Camera, type Object3D, Vector3 } from 'three'
import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import {
clearBoxSelectHandled,
isBoxSelectPointerSuppressed,
@@ -191,6 +192,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
const currentClientXRef = useRef(0)
const currentClientYRef = useRef(0)
const spaceDownRef = useRef(false)
// rAF throttle for the expensive marquee preview pass. pointermove can fire
// several times per animation frame; the per-node AABB projection in
// `collectNodeIdsInScreenRect` only needs to run once per frame. We stash the
// latest clamped rect and process it inside the rAF callback.
const previewRafRef = useRef<number | null>(null)
const pendingPreviewRectRef = useRef<ScreenRect | null>(null)
const syncPreviewSelectedIds = useCallback(
(nextIds: string[]) => {
@@ -206,6 +213,11 @@ const ScreenRectangleSelectTool: React.FC = () => {
pointerDownRef.current = false
isDraggingRef.current = false
pointerIdRef.current = null
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current)
previewRafRef.current = null
}
pendingPreviewRectRef.current = null
hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([])
@@ -213,6 +225,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
useViewer.getState().setInputDragging(false)
ownsInputDraggingRef.current = false
}
useInteractionScope.getState().endIf((s) => s.kind === 'box-select')
}, [syncPreviewSelectedIds])
useEffect(() => {
@@ -263,6 +276,14 @@ const ScreenRectangleSelectTool: React.FC = () => {
useEffect(() => {
const canvas = gl.domElement
const flushPreview = () => {
previewRafRef.current = null
const rect = pendingPreviewRectRef.current
if (!rect) return
pendingPreviewRectRef.current = null
syncPreviewSelectedIds(collectNodeIdsInScreenRect(rect, camera, canvas))
}
const updateDrag = (event: PointerEvent) => {
if (!pointerDownRef.current) return
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
@@ -291,6 +312,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
isDraggingRef.current = true
ownsInputDraggingRef.current = true
useViewer.getState().setInputDragging(true)
useInteractionScope.getState().begin({ kind: 'box-select' })
markBoxSelectHandled()
try {
canvas.setPointerCapture(event.pointerId)
@@ -311,13 +333,22 @@ const ScreenRectangleSelectTool: React.FC = () => {
screenRectFromDomRect(canvas.getBoundingClientRect()),
)
if (!clampedRect) {
if (previewRafRef.current !== null) {
cancelAnimationFrame(previewRafRef.current)
previewRafRef.current = null
}
pendingPreviewRectRef.current = null
hideScreenRectangleSelectionElement(elementRef.current)
syncPreviewSelectedIds([])
return
}
updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas))
// Coalesce the per-node AABB projection to one run per animation frame.
pendingPreviewRectRef.current = clampedRect
if (previewRafRef.current === null) {
previewRafRef.current = requestAnimationFrame(flushPreview)
}
}
const finishDrag = (event: PointerEvent) => {
@@ -1,6 +1,6 @@
'use client'
import { sceneRegistry } from '@pascal-app/core'
import { type AnyNodeId, resolveFacingIndicator, sceneRegistry, useScene } from '@pascal-app/core'
import { useEffect, useMemo } from 'react'
import {
Box3,
@@ -15,6 +15,7 @@ import {
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import useFacingPose from '../../../store/use-facing-pose'
const NO_RAYCAST = () => null
@@ -93,6 +94,9 @@ export function DragBoundingBox({
centerY,
color = DEFAULT_COLOR,
}: DragBoundingBoxProps) {
const nodeType = useScene((state) => state.nodes[nodeId as AnyNodeId]?.type)
const facing = nodeType ? resolveFacingIndicator(nodeType) : null
const measured = useMemo(() => {
if (size) return null
const obj = sceneRegistry.nodes.get(nodeId)
@@ -104,6 +108,7 @@ export function DragBoundingBox({
? [0, centerY ?? size[1] / 2, 0]
: (measured?.center ?? [0, fallbackSize[1] / 2, 0])
const minY = cy - h / 2
const groundY = minY + 0.01
const edgeGeometry = useMemo(() => {
const box = new BoxGeometry(w, h, d)
@@ -117,9 +122,9 @@ export function DragBoundingBox({
const planeGeometry = useMemo(() => {
const plane = new PlaneGeometry(w, d)
plane.rotateX(-Math.PI / 2)
plane.translate(cx, minY + 0.01, cz)
plane.translate(cx, groundY, cz)
return plane
}, [w, d, cx, minY, cz])
}, [w, d, cx, groundY, cz])
const edgeMaterial = useMemo(
() => new LineBasicNodeMaterial({ color, linewidth: 3, depthTest: false, depthWrite: false }),
@@ -147,6 +152,22 @@ export function DragBoundingBox({
[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
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 point = levelNode ? event.localPosition : event.position
const rawPoint: [number, number] = [point[0], point[2]]
const bypassSnap = event.nativeEvent.shiftKey === true
const gridPoint: [number, number] = bypassSnap
? rawPoint
: [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
// Snapping follows the active mode (snapToHalf returns raw in Off / non-grid);
// no Shift bypass — Shift cycles the mode, Off is the bypass.
const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
const newPosition =
dragState?.isDragging && 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
if (
!bypassSnap &&
dragState?.isDragging &&
previousPositionRef.current &&
(newPosition[0] !== previousPositionRef.current[0] ||
@@ -17,6 +17,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary'
import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import {
ARROW_COLOR,
ARROW_HOVER_COLOR,
@@ -376,14 +377,20 @@ export const SiteBoundaryEditor: React.FC = () => {
if (!siteId) return
const editor = useEditor.getState()
if (isDragging) {
editor.setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
} else if (
editor.activeHandleDrag?.nodeId === siteId &&
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
) {
editor.setActiveHandleDrag(null)
useInteractionScope
.getState()
.begin({ kind: 'handle-drag', nodeId: siteId, handle: 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
) {
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
}
}
if (!isDragging) {
@@ -396,12 +403,14 @@ export const SiteBoundaryEditor: React.FC = () => {
useEffect(
() => () => {
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 (
editor.activeHandleDrag?.nodeId === siteId &&
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
activeHandleDrag?.nodeId === siteId &&
activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
) {
editor.setActiveHandleDrag(null)
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
}
useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
isDraggingSiteBoundaryRef.current = false
@@ -23,6 +23,8 @@ import {
resolveStairDestinationLevel,
resolveStairPlacementLevelId,
} 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 { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import {
@@ -263,7 +265,19 @@ export const StairTool: React.FC = () => {
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 key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}`
if (key === lastPreviewKey) return
lastPreviewKey = key
const preview = buildPreviewScene(position, rotation)
const visualPosition = preview
? getFloorStackPreviewPosition({
@@ -287,6 +301,19 @@ export const StairTool: React.FC = () => {
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) {
openingPreview.clear()
return
@@ -319,7 +346,8 @@ export const StairTool: React.FC = () => {
// 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
// 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 = (
gridX: number,
gridZ: number,
@@ -348,20 +376,26 @@ export const StairTool: React.FC = () => {
}
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(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
isGridSnapActive()
? 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[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position
applyDraftPreview(position, rotationRef.current)
if (
!bypassSnap &&
(isGridSnapActive() || isMagneticSnapActive()) &&
previousGridPosRef.current &&
(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 bypassSnap = event.nativeEvent?.shiftKey === true
const step = useEditor.getState().gridSnapStep
const [gridX, gridZ] = alignPoint(
bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2,
bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2,
isGridSnapActive()
? 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[2],
event.nativeEvent?.altKey === true || bypassSnap,
!isMagneticSnapActive(),
)
return [gridX, 0, gridZ]
}
@@ -398,8 +436,20 @@ export const StairTool: React.FC = () => {
commitStairPlacement(currentLevelId, position, rotationRef.current)
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()
// 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) => {
@@ -444,6 +494,7 @@ export const StairTool: React.FC = () => {
window.removeEventListener('keydown', onKeyDown)
useAlignmentGuides.getState().clear()
openingPreview.clear()
useFacingPose.getState().clear()
}
}, [currentLevelId])
@@ -451,7 +502,9 @@ export const StairTool: React.FC = () => {
<group>
<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}>
<mesh castShadow geometry={previewGeometry}>
<meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
@@ -2,13 +2,22 @@ import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
type FenceNode,
nodeRegistry,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
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 {
useEditingHole,
useEndpointReshape,
useIsCurveReshape,
useMovingNode,
useReshapingNode,
} from '../../store/use-interaction-scope'
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-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 { RoofTool } from './roof/roof-tool'
import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
import { FacingPoseIndicator } from './shared/facing-pose-indicator'
import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { StairTool } from './stair/stair-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -55,12 +65,23 @@ export const ToolManager: React.FC = () => {
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole)
const movingNode = useMovingNode()
const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
const reshapingNode = useReshapingNode()
// 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 selectedIds = useViewer((state) => state.selection.selectedIds)
const buildingId = useViewer((state) => state.selection.buildingId)
@@ -134,6 +155,16 @@ export const ToolManager: React.FC = () => {
// Show build tools when in build mode
const showBuildTool = mode === 'build' && tool !== null
// A move initiated from the 2D floor-plan (orange move-dot) is owned end-to-
// end by `FloorplanRegistryMoveOverlay`, which marks the origin `'2d'` at
// dot-down. Mounting the 3D affordance mover alongside it would adopt the
// same node and, on its unmount, restore the adopt-time position — snapping
// the committed 2D move back to its start. Gate the 3D mover off for 2D moves
// (the scene writes the overlay makes still mirror into the 3D view). A
// 3D-initiated move leaves the origin null until its own commit, so this only
// suppresses the 3D tool for genuinely 2D-owned moves.
const showMover = movingNode != null && movingNodeOrigin !== '2d'
// Registry-first: if the active tool's kind has a NodeDefinition with a
// tool contribution, the registry-driven tool takes over.
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
@@ -163,7 +194,7 @@ export const ToolManager: React.FC = () => {
<>
{/* World-space tools: site boundary and building movement operate in world coordinates */}
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{movingNode?.type === 'building' && (
{showMover && movingNode?.type === 'building' && (
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
)}
@@ -217,49 +248,30 @@ export const ToolManager: React.FC = () => {
</Suspense>
) : null
})()}
{movingWallEndpoint &&
{endpointTarget &&
reshapingNode &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(
movingWallEndpoint.wall.type,
reshapingNode.type,
'move-endpoint',
)
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance target={movingWallEndpoint} />
<RegistryAffordance target={endpointTarget} />
</Suspense>
) : null
})()}
{movingFenceEndpoint &&
{isCurveReshape &&
reshapingNode &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(
movingFenceEndpoint.fence.type,
'move-endpoint',
)
const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance target={movingFenceEndpoint} />
<RegistryAffordance node={reshapingNode} />
</Suspense>
) : null
})()}
{curvingWall &&
(() => {
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' && (
{showMover && movingNode.type !== 'building' && (
<MoveTool
onNodeMoved={handlePlacedNodeSelected}
onSpawnMoved={handlePlacedNodeSelected}
@@ -284,6 +296,10 @@ export const ToolManager: React.FC = () => {
tools above. Lives inside the building-local group so the
building-local guide coords render at the right world position. */}
<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,
published by the door/window move tools in the same world frame. */}
<OpeningGuides3DLayer />
@@ -13,7 +13,8 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
import {
distanceSquared,
findWallSnapTarget,
@@ -51,10 +52,15 @@ type WallSplitIntersection = {
}
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 {
if (step <= 0) return value
return Math.round(value / step) * step
}
@@ -404,32 +410,38 @@ export function createWallOnCurrentLevel(
let resolvedStart = start
let resolvedEnd = end
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
const splitEnd = splitWallIfNeeded(
endIntersection,
workingWalls,
nodes,
createNodes,
updateNodes,
deleteNode,
)
if (splitEnd) {
workingWalls = splitEnd.walls
resolvedEnd = splitEnd.point
}
// The corner-join / wall-split snap on commit is a magnetic (line) snap, so
// it must be gated by the snapping mode like the draft preview is. Without
// this gate `'off'` (and `'angles'`) still snapped the committed endpoint to
// existing wall geometry — the residual snap the draft path no longer does.
if (isMagneticSnapActive()) {
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
const splitEnd = splitWallIfNeeded(
endIntersection,
workingWalls,
nodes,
createNodes,
updateNodes,
deleteNode,
)
if (splitEnd) {
workingWalls = splitEnd.walls
resolvedEnd = splitEnd.point
}
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
const splitStart = splitWallIfNeeded(
startIntersection,
workingWalls,
nodes,
createNodes,
updateNodes,
deleteNode,
)
if (splitStart) {
workingWalls = splitStart.walls
resolvedStart = splitStart.point
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
const splitStart = splitWallIfNeeded(
startIntersection,
workingWalls,
nodes,
createNodes,
updateNodes,
deleteNode,
)
if (splitStart) {
workingWalls = splitStart.walls
resolvedStart = splitStart.point
}
}
if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
@@ -13,7 +13,7 @@ import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } fro
import { EDITOR_LAYER } from './../../../lib/constants'
import { sfxEmitter } from './../../../lib/sfx-bus'
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'
const Y_OFFSET = 0.02
@@ -65,7 +65,6 @@ export const ZoneTool: React.FC = () => {
const pointsRef = useRef<Array<[number, number]>>([])
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const levelYRef = useRef(0) // Track current level Y position
const shiftPressed = useRef(false)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setTool = useEditor((state) => state.setTool)
@@ -86,21 +85,19 @@ export const ZoneTool: React.FC = () => {
mainLineRef.current.geometry = new BufferGeometry()
closingLineRef.current.geometry = new BufferGeometry()
// 15° angle snap from the last vertex by default. Shift bypasses all snap.
// Distance snaps along the ray so the vertex lands on
// grid-multiple lengths without leaving the ray.
// Snapping follows the active mode (zone resolves to the 'wall' context):
// `angles` locks the ray to 15° from the last vertex, `grid` quantizes the
// distance along it, `lines` / `off` leave the raw cursor. No held-Shift
// bypass — Shift cycles the mode (see interaction-scope.md).
const snapDraftPoint = (
lastPoint: [number, number],
gridPoint: [number, number],
_gridPoint: [number, number],
rawPoint: [number, number],
): [number, number] => {
if (shiftPressed.current) return rawPoint
const [x, z] = snapPointAlongAngleRay(
lastPoint,
rawPoint,
DEFAULT_ANGLE_STEP,
useEditor.getState().gridSnapStep,
)
const angleStep = isAngleSnapActive() ? DEFAULT_ANGLE_STEP : 0
const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
if (angleStep === 0 && gridStep === 0) return rawPoint
const [x, z] = snapPointAlongAngleRay(lastPoint, rawPoint, angleStep, gridStep)
return [x, z]
}
@@ -172,15 +169,16 @@ export const ZoneTool: React.FC = () => {
if (!cursorRef.current) return
// World-grid snap projected into building-local; rotated buildings
// used to pull the snap off the visible grid lines.
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const [gridX, gridZ] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
// used to pull the snap off the visible grid lines. Grid quantize only
// in grid mode; off / lines / angles leave the raw cursor for the first
// vertex (later vertices snap along the ray in `snapDraftPoint`).
const [gridX, gridZ] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
cursorPosition = [gridX, gridZ]
rawCursorPosition = [event.localPosition[0], event.localPosition[2]]
levelYRef.current = event.localPosition[1]
@@ -191,9 +189,10 @@ export const ZoneTool: React.FC = () => {
? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
: 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 (
!bypassSnap &&
(isGridSnapActive() || isAngleSnapActive()) &&
pointsRef.current.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
@@ -211,14 +210,13 @@ export const ZoneTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const [gridX, gridZ] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapWorldXZForActiveBuilding(
const [gridX, gridZ] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
let clickPoint: [number, number] = [gridX, gridZ]
// 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
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
@@ -9,7 +9,7 @@ import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor'
import { CameraActions } from './camera-actions'
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
// 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">
<ControlModes />
</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">
<GridSnapControl />
<SecondaryToggles />
</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">
<ControlModes />
<div className="mx-1 h-5 w-px bg-border" />
<GridSnapControl />
<SecondaryToggles />
<div className="mx-1 h-5 w-px bg-border" />
<CameraActions />
@@ -1,6 +1,5 @@
'use client'
import { Icon } from '@iconify/react'
import {
type AnyNodeId,
type BuildingNode,
@@ -16,23 +15,17 @@ import { useShallow } from 'zustand/react/shallow'
import { getLevelDisplayName } from '@pascal-app/core'
import { createLocalGuideImage } from '../../../lib/local-guide-image'
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 { SliderControl } from '../controls/slider-control'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
import { ActionButton } from './action-button'
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
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 =
'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 ──────────────────────────
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 ─────────────────────────────────────────────────
function ScansControl() {
@@ -1014,8 +943,6 @@ function RiserControl() {
// ── Exports ─────────────────────────────────────────────────────────────────
export { GridSnapControl }
export function SecondaryToggles() {
return (
<div className="flex items-center gap-1">
@@ -1027,7 +954,6 @@ export function SecondaryToggles() {
export function ViewToggles() {
return (
<div className="flex items-center gap-1">
<GridSnapControl />
<ScansControl />
<GuidesControl />
<ReferenceFloorControl />
@@ -1,32 +1,19 @@
import { ShortcutToken } from '../primitives/shortcut-token'
import { ContextualHelperPanel } from './contextual-helper-panel'
interface BuildingHelperProps {
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) {
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">
<div className="flex items-center gap-2 text-sm">
<ShortcutToken value="Left click" />
<span className="text-muted-foreground">Place building</span>
</div>
{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>
<ContextualHelperPanel
hints={[
{ keys: ['Left click'], label: 'Place building' },
...(showRotate ? [{ keys: ['R', 'T'], label: 'Rotate' }] : []),
{ keys: ['Esc'], label: 'Cancel' },
]}
/>
)
}
@@ -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 { 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 useEditor, { type GridSnapStep } from '../../../store/use-editor'
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[] }) {
return (
<div className="flex flex-wrap items-center gap-0.5">
<div className={KEY_CELL_CLASS}>
{keys.map((key, index) => (
<div className="flex items-center gap-0.5" key={`${key}-${index}`}>
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">+</span> : null}
<ShortcutToken className="h-5 px-1.5 text-[10px]" value={key} />
</div>
<Fragment key={`${key}-${index}`}>
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">/</span> : null}
<ShortcutToken className={TOKEN_CLASS} value={key} />
</Fragment>
))}
</div>
)
}
export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) {
if (hints.length === 0) return null
// Shared single-line chip row (key cell + icon/label cell). Rendered either as a
// 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 (
<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) => (
<div
className={cn(
'grid min-w-0 grid-cols-1 gap-1 rounded-md text-sm',
hint.active && '-mx-1 bg-primary/10 px-1.5 py-1 text-foreground',
)}
className={cn(ROW_CLASS, 'items-start', hint.active && 'rounded-md bg-primary/10')}
key={`${hint.keys.join('+')}:${hint.label}`}
>
<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>
@@ -10,15 +10,43 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useIsMobile } from '../../../hooks/use-mobile'
import { resolveSelectModeHelpHints } from '../../../lib/contextual-help'
import {
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 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 { ContextualHelperPanel } from './contextual-helper-panel'
import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-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 = {
command: boolean
shift: boolean
@@ -61,7 +89,9 @@ function useActiveModifierKeys(): ActiveModifierKeys {
export function HelperManager() {
const mode = useEditor((s) => s.mode)
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 isMobile = useIsMobile()
const modifiers = useActiveModifierKeys()
@@ -72,6 +102,23 @@ export function HelperManager() {
.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 single = selectedNodes.length === 1 ? selectedNodes[0] : null
const mepSelection =
@@ -93,32 +140,78 @@ export function HelperManager() {
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null
// Rotating a node via its in-world gizmo: advertise Shift = free rotation,
// the same angle-step bypass wall drafting exposes. Takes priority over the
// idle select-mode hints since a handle drag is the active interaction.
if (activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL) {
return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
}
// 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.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') {
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} />
}
// Registry-first: kinds with `def.toolHints` render through the generic
// `RegisteredToolHelper`. Today that covers ceiling / door / fence /
// item / shelf / slab / spawn / wall / window.
// Legacy fallback — only `roof` remains because it hasn't migrated to
// `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the
// 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) {
const def = nodeRegistry.get(tool)
if (def?.toolHints && def.toolHints.length > 0) {
return <RegisteredToolHelper hints={def.toolHints} shiftPressed={modifiers.shift} />
const hints = def?.toolHints ?? []
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
}
@@ -1,24 +1,36 @@
import type { ContinuationContext } from '../../../lib/continuation'
import type { SnapContext } from '../../../lib/snapping-mode'
import { ContextualHelperPanel } from './contextual-helper-panel'
interface ItemHelperProps {
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 (
<ContextualHelperPanel
continuationContext={continuationContext}
hints={[
{ keys: ['Left click'], label: 'Place item' },
{ keys: ['R'], label: 'Rotate counterclockwise' },
{ keys: ['T'], label: 'Rotate clockwise' },
{
keys: ['Shift'],
label: shiftPressed ? 'Guided constraints bypassed' : 'Free place',
active: shiftPressed,
},
{ keys: ['Left click'], label: 'Place' },
{ keys: ['R', 'T'], label: 'Rotate' },
...(showForce ? [{ keys: ['Alt'], label: 'Force place' }] : []),
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
]}
snapContext={snapContext}
/>
)
}
@@ -1,4 +1,7 @@
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'
/**
@@ -13,19 +16,40 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
export function RegisteredToolHelper({
hints,
shiftPressed = false,
snapContext = null,
continuationContext = null,
}: {
hints: ToolHint[]
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 (
<ContextualHelperPanel
hints={hints.map((hint) => ({
keys: [hint.key],
label:
shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label,
active: shiftPressed && hint.key === 'Shift',
}))}
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],
label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label,
active: shiftPressed && isBypassHint,
}
})}
continuationContext={continuationContext}
snapContext={snapContext}
/>
)
}
@@ -1,17 +1,14 @@
import type { SnapContext } from '../../../lib/snapping-mode'
import { ContextualHelperPanel } from './contextual-helper-panel'
export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) {
export function RoofHelper({ snapContext }: { snapContext?: SnapContext | null }) {
return (
<ContextualHelperPanel
hints={[
{ keys: ['Left click'], label: 'Set corner' },
{
keys: ['Shift'],
label: shiftPressed ? 'Guided constraints bypassed' : 'Free corner',
active: shiftPressed,
},
{ 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) {
return <>{emptyState}</>
}
@@ -22,6 +22,17 @@ const MOUSE_SHORTCUTS = {
},
} as const
// The platform-agnostic command modifier. Both Cmd and Ctrl bind the action; we
// render the symbol for the *current* device so the hint reads native (⌘ on Mac,
// Ctrl elsewhere) without implying only one of them works.
const COMMAND_VALUES = new Set(['Cmd/Ctrl', 'Cmd', 'Command', 'Meta'])
// Resolved once on the client at module load — the editor HUD is client-only, so
// there's no server render to mismatch against. `navigator.platform` is enough
// here and matches the detection used elsewhere (floorplan rotate hint).
const IS_MAC =
typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC')
type ShortcutTokenProps = React.ComponentProps<'kbd'> & {
value: string
displayValue?: string
@@ -30,16 +41,19 @@ type ShortcutTokenProps = React.ComponentProps<'kbd'> & {
function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTokenProps) {
const mouseShortcut =
value in MOUSE_SHORTCUTS ? MOUSE_SHORTCUTS[value as keyof typeof MOUSE_SHORTCUTS] : null
const isCommand = COMMAND_VALUES.has(value)
const commandDisplay = IS_MAC ? '⌘' : 'Ctrl'
const commandLabel = IS_MAC ? 'Command' : 'Control'
return (
<kbd
aria-label={mouseShortcut?.label ?? displayValue ?? value}
aria-label={mouseShortcut?.label ?? (isCommand ? commandLabel : (displayValue ?? value))}
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',
mouseShortcut && 'justify-center px-1.5',
className,
)}
title={mouseShortcut?.label ?? value}
title={mouseShortcut?.label ?? (isCommand ? commandLabel : value)}
{...props}
>
{mouseShortcut ? (
@@ -54,6 +68,10 @@ function ShortcutToken({ className, displayValue, value, ...props }: ShortcutTok
/>
<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)
)}
@@ -183,7 +183,6 @@ export function SettingsPanel({
const clearScene = useScene((state) => state.clearScene)
const resetSelection = useViewer((state) => state.resetSelection)
const exportScene = useViewer((state) => state.exportScene)
const showGrid = useViewer((state) => state.showGrid)
const shadows = useViewer((state) => state.shadows)
const setPhase = useEditor((state) => state.setPhase)
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
@@ -331,16 +330,6 @@ export function SettingsPanel({
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
/>
</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>
<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 { useFrame } from '@react-three/fiber'
import type { Mesh } from 'three'
import { resolveOverlayPolicy } from '../lib/interaction/overlay-policy'
import useEditor from '../store/use-editor'
import useInteractionScope from '../store/use-interaction-scope'
export const ViewerZoneSystem = () => {
useFrame(() => {
const { levelId, zoneId } = useViewer.getState().selection
const structureLayer = useEditor.getState().structureLayer
const nodes = useScene.getState().nodes
// During any active interaction zone labels step back entirely (Sims-light).
const zoneLabelsHidden =
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
sceneRegistry.byType.zone!.forEach((id) => {
const obj = sceneRegistry.nodes.get(id)
@@ -35,7 +40,7 @@ export const ViewerZoneSystem = () => {
})
// Labels: always visible on the current level (regardless of mode or zone selection)
const showLabel = !!levelId && isOnSelectedLevel
const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
const targetOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${id}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) {
@@ -12,6 +12,7 @@ import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import useEditor from '../store/use-editor'
import { getMovingNode } from '../store/use-interaction-scope'
const UP = new Vector3(0, 1, 0)
@@ -59,7 +60,7 @@ export function useCeilingEvents() {
const isActive = (): boolean => {
const ed = useEditor.getState()
if (ed.selectedItem?.attachTo === 'ceiling') return true
const moving = ed.movingNode
const moving = getMovingNode()
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 { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { steppedRotation } from '../components/tools/item/placement-math'
import { toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history'
import {
@@ -9,7 +10,8 @@ import {
} from '../lib/scene-clipboard'
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
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,
// 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.
const isPlacingOpening = () => {
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')
}
// 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) => {
if (e.key === 'Control' || e.key === 'Meta') {
// Only a fresh, modifier-free press starts a clean-tap candidate;
// ignore key-repeat and presses already part of a combo.
ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey
} else {
// Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
// the clean tap.
ctrlTapClean = false
}
// Don't handle shortcuts if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
if (e.key === 'Shift' && !e.repeat && 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') {
e.preventDefault()
_toolCancelConsumed = false
@@ -57,7 +120,9 @@ export const useKeyboard = ({
const currentPhase = useEditor.getState().phase
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
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
@@ -91,6 +156,9 @@ export const useKeyboard = ({
e.preventDefault()
useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('build')
// Set the item tool explicitly so the active tool never inherits a
// stale tool from a prior build session.
useEditor.getState().setTool('item')
useEditor.getState().setActiveSidebarPanel('items')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
@@ -98,6 +166,8 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('zones')
useEditor.getState().setMode('build')
// Set the zone tool explicitly so it never inherits a stale tool.
useEditor.getState().setTool('zone')
}
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
@@ -109,6 +179,9 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('build')
// Set the wall tool explicitly so B never inherits a stale tool
// (e.g. fence) left over from a prior build session.
useEditor.getState().setTool('wall')
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
@@ -227,14 +300,18 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:item-rotate')
} else if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
// Handle different rotation types (number for roof, array for items/windows/doors)
// Round to the nearest 45° then step one increment (not a blind +45°).
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)) {
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')
@@ -260,13 +337,18 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:item-rotate')
} else if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
// Round to the nearest 45° then step one increment back.
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)) {
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')
@@ -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)
return () => window.removeEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [disabled, isVersionPreviewMode])
return null
+42 -3
View File
@@ -155,6 +155,7 @@ export {
snapWallDraftPoint,
snapWallDraftPointDetailed,
WALL_GRID_STEP,
WALL_JOIN_SNAP_RADIUS,
type WallDraftSnapKind,
type WallDraftSnapResult,
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.).
export { markToolCancelConsumed } from './hooks/use-keyboard'
export { type Selection, useSelection } from './hooks/use-selection'
export {
clearPlacementSurface,
getPlacementSurface,
type PlacementSurface,
publishPlacementSurface,
} from './lib/active-placement-surface'
export {
CEILING_ALIGNMENT_THRESHOLD_M,
type CeilingPlanSnapInput,
@@ -239,6 +246,13 @@ export {
} from './lib/ceiling-plan-snap'
export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels.
export {
CONTINUATION_PROFILES,
type ContinuationContext,
type ContinuationMode,
continuationContextOf,
nextContinuation,
} from './lib/continuation'
export {
resolveCurrentBuildingId,
resolveElevatorNodeSupportY,
@@ -267,6 +281,13 @@ export {
} from './lib/floorplan'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { exportSceneToGlb } from './lib/glb-export'
export {
boundaryReshapeScope,
curveReshapeScope,
endpointReshapeScope,
holeEditScope,
movingNodeOf,
} from './lib/interaction/scope'
export {
buildResetSurfaceMaterialUpdates,
buildRoofSurfaceMaterialPatch,
@@ -341,15 +362,33 @@ export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export type {
FloorplanSelectionTool,
MovingFenceEndpoint,
MovingWallEndpoint,
SplitOrientation,
Tool,
ToolDefaults,
ViewMode,
WorkspaceMode,
} 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 {
default as useOpeningGuides,
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'],
label: 'Drag left or right to rotate selected object',
})
expect(hints).toContainEqual({
// The Shift bypass hint is gated to the in-progress direct-move gesture
// (Cmd/Ctrl held); on an idle selection it must not appear (Shift there
// means multi-select, not bypass).
expect(hints).not.toContainEqual({
keys: ['Shift'],
label: 'Hold to bypass snaps and angle steps',
active: false,
+38 -5
View File
@@ -1,9 +1,37 @@
export type ContextualShortcutHint = {
keys: string[]
label: string
// Optional secondary line under the label for a terser qualifier
// (e.g. "disable 15° snap"). The HUD wraps both lines rather than truncating.
subtitle?: string
active?: boolean
}
// `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 = {
selectedCount: number
hasMovableSelection: boolean
@@ -101,11 +129,16 @@ export function resolveSelectModeHelpHints({
}
}
hints.push({
keys: [SHIFT_KEY],
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
active: shiftPressed,
})
// The Shift bypass only applies to an in-progress direct move/rotate
// (the Cmd/Ctrl-drag gesture), so only surface it while that modifier is
// engaged — not on an idle selection, where Shift means multi-select.
if (commandPressed && (hasMovableSelection || hasRotatableSelection)) {
hints.push({
keys: [SHIFT_KEY],
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
active: shiftPressed,
})
}
if (!commandPressed) {
hints.push({
+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'
type EditorState = ReturnType<typeof useEditor.getState>
type EndpointEngager = (node: AnyNode, endpoint: 'start' | 'end', editor: EditorState) => void
import useInteractionScope from '../store/use-interaction-scope'
import { endpointReshapeScope } from './interaction/scope'
/**
* Per-kind endpoint-move engagement. Kinds whose 2D endpoint drag
* needs its own store field (wall ↔ `movingWallEndpoint`, fence ↔
* `movingFenceEndpoint`) register their bridge here. The dispatcher
* 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.
* Concrete {@link EditorApi} backed by `useEditor` + the interaction scope.
* Descriptors call into editor state through this interface; the editor owns
* the actual store wiring so core stays decoupled.
*
* `engageMove` clears any in-progress endpoint drag or curve gesture so
* the move tool takes over cleanly — mirrors the legacy bookkeeping that
* lived inside `WallMoveArrowHandle.activateWallMove` / `FenceMoveArrowHandle`.
* `engageMove` no longer clears any in-progress endpoint drag or curve gesture:
* `setMovingNode` begins the `moving` scope, and the scope is single-owner, so
* it atomically replaces any prior reshape — there is no separate flag to reset.
*/
export function createEditorApi(): EditorApi {
return {
@@ -39,10 +22,6 @@ export function createEditorApi(): EditorApi {
// cast lets registry-driven move kinds through without forcing a
// schema-level type widening.
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
editor.setMovingWallEndpoint(null)
editor.setMovingFenceEndpoint(null)
editor.setCurvingWall(null)
editor.setCurvingFence(null)
},
engageMoveDrag(node: AnyNode) {
const editor = useEditor.getState()
@@ -50,13 +29,12 @@ export function createEditorApi(): EditorApi {
// it at setup and wires its commit-on-release listener.
editor.setPlacementDragMode(true)
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') {
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]
}
// Converts a world X/Z point into the floor-plan-local (building-local)
// frame used by the SVG scene `<g>` and every stored node position. The
// inverse of `floorplanLocalToWorldPoint`. Shared so the floor-plan panel
// and the 2D move overlay resolve the same frame — feeding a world-space
// `original` into a local-space cursor solver lands the drop off by the
// building's world X/Z (worse for an off-origin building).
export function worldToFloorplanLocalPoint(
worldX: number,
worldZ: number,
buildingPosition: readonly [number, number, number],
buildingRotationY: number,
): Point2D {
const dx = worldX - buildingPosition[0]
const dz = worldZ - buildingPosition[2]
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
return {
x: dx * cos - dz * sin,
y: dx * sin + dz * cos,
}
}
// Inverse of `worldToFloorplanLocalPoint`: floor-plan-local X/Y → world X/Z.
export function floorplanLocalToWorldPoint(
point: Point2D | [number, number],
buildingPosition: readonly [number, number, number],
buildingRotationY: number,
): { x: number; z: number } {
const localX = Array.isArray(point) ? point[0] : point.x
const localY = Array.isArray(point) ? point[1] : point.y
const cos = Math.cos(buildingRotationY)
const sin = Math.sin(buildingRotationY)
return {
x: buildingPosition[0] + localX * cos + localY * sin,
z: buildingPosition[2] - localX * sin + localY * cos,
}
}
export function getRotatedRectanglePolygon(
center: Point2D,
width: number,
@@ -8,6 +8,7 @@ export {
export {
clampPlanValue,
doesPolygonIntersectSelectionBounds,
floorplanLocalToWorldPoint,
getDistanceToWallSegment,
getFloorplanSelectionBounds,
getPlanPointDistance,
@@ -20,6 +21,7 @@ export {
movePlanPointTowards,
pointMatchesWallPlanPoint,
rotatePlanVector,
worldToFloorplanLocalPoint,
} from './geometry'
export {
buildFloorplanItemEntry,
@@ -0,0 +1,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.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',
catalogCategory: null,
selectedItem: null,
movingNode: null,
selectedReferenceId: null,
spaces: {},
editingHole: null,
hoveredHole: null,
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,
} from '../components/tools/wall/wall-drafting'
import useAlignmentGuides from '../store/use-alignment-guides'
import useEditor from '../store/use-editor'
import { isMagneticSnapActive } from '../store/use-editor'
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
@@ -181,7 +181,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
const nodes = input.nodes ?? useScene.getState().nodes
const walls = getLevelWalls(nodes, input.levelId, input.walls)
const fallbackPoint = input.fallbackPoint
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap
const magnetic = input.magnetic ?? isMagneticSnapActive()
const wallSnap = snapWallDraftPointDetailed({
point: input.rawPoint,
@@ -209,8 +209,11 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
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
if (input.align === false || input.altKey) {
if (input.align === false || input.altKey || !magnetic) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
+290 -100
View File
@@ -16,6 +16,7 @@ import {
type FenceNode,
type ItemNode,
type LevelNode,
nodeRegistry,
type RoofNode,
type RoofSegmentNode,
type RoofSurfaceMaterialRole,
@@ -33,6 +34,13 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import {
CONTINUATION_PROFILES,
type ContinuationContext,
type ContinuationMode,
continuationContextOf,
nextContinuation,
} from '../lib/continuation'
import {
type ActivePaintMaterial,
type PaintableMaterialTarget,
@@ -40,6 +48,21 @@ import {
resolvePaintTargetFromSelection,
type SingleSurfaceMaterialRole,
} 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_FLOORPLAN_PANE_RATIO = 0.5
@@ -160,16 +183,6 @@ export type Tool = SiteTool | StructureTool | FurnishTool
*/
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 =
| WallSurfaceSide
| StairSurfaceMaterialRole
@@ -219,25 +232,6 @@ type EditorState = {
setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: AssetInput | null
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
* cross) rather than a click-to-place flow. The placement coordinator reads
@@ -285,22 +279,6 @@ type EditorState = {
*/
movingNodeOrigin: '2d' | '3d' | null
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
* full 3D orientation (duct fittings). Alt cycles it Y → X → Z; the
@@ -309,23 +287,42 @@ type EditorState = {
*/
rotationAxis: '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
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
activePaintMaterial: ActivePaintMaterial | null
setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
activePaintTarget: PaintableMaterialTarget
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
// default material instead of applying `activePaintMaterial`.
paintEraser: boolean
setPaintEraser: (eraser: boolean) => void
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
// What the cursor is over in paint mode: the scopes it offers + labels for the
// 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
setSelectedReferenceId: (id: string | null) => void
guideUi: Record<string, GuideUiState>
@@ -335,9 +332,6 @@ type EditorState = {
// Space detection for cutaway mode
spaces: Record<string, Space>
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
setHoveredHole: (hole: SurfaceHoleTarget | null) => void
// Preview mode (viewer-like experience inside the editor)
@@ -374,11 +368,28 @@ type EditorState = {
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep
setGridSnapStep: (step: GridSnapStep) => void
// Cycles the grid step through GRID_SNAP_STEPS (0.5 → 0.25 → 0.1 → 0.05 →
// 0.5) and returns the new value. Bound to the measurement-step shortcut.
cycleGridSnapStep: () => GridSnapStep
// Magnetic snapping while drafting — snaps wall endpoints onto existing
// wall corners / wall bodies (the "magnetic" beacon). Independent of grid
// snap. On by default; toggled from the Display menu.
magneticSnap: boolean
setMagneticSnap: (enabled: boolean) => void
// 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
toggleReferenceFloor: () => void
setShowReferenceFloor: (show: boolean) => void
@@ -427,6 +438,8 @@ type PersistedEditorLayoutState = Pick<
| 'floorplanSelectionTool'
| 'gridSnapStep'
| 'magneticSnap'
| 'snappingModeByContext'
| 'continuationByContext'
| 'showReferenceFloor'
| 'referenceFloorOffset'
| 'referenceFloorOpacity'
@@ -450,6 +463,16 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
floorplanSelectionTool: 'click',
gridSnapStep: 0.5,
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,
referenceFloorOffset: 1,
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(
state: Partial<PersistedEditorLayoutState> | null | undefined,
state: (Partial<PersistedEditorLayoutState> & LegacyContinuationState) | null | undefined,
): PersistedEditorLayoutState {
return {
activeSidebarPanel:
@@ -568,6 +631,12 @@ function normalizePersistedEditorLayoutState(
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
// Default on: only an explicit persisted `false` disables it.
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,
referenceFloorOffset:
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
@@ -760,6 +829,10 @@ const useEditor = create<EditorState>()(
else if (tool) {
set({ tool: null })
}
const scope = useInteractionScope.getState()
if (mode === 'material-paint') scope.begin({ kind: 'painting' })
else scope.endIf((s) => s.kind === 'painting')
},
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
setTool: (tool) => set({ tool }),
@@ -795,44 +868,41 @@ const useEditor = create<EditorState>()(
setCatalogCategory: (category) => set({ catalogCategory: category }),
selectedItem: null,
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,
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
setMovingNode: (node) =>
set(
node === null
? // Preserve `movingNodeOrigin` across the clear so the
// non-owning side's effect cleanup — which fires after
// `setMovingNode(null)` propagates — can still read who
// finalised. The next non-null `setMovingNode` resets it.
// Always clear the press-drag flag when a move ends.
{ movingNode: null, placementDragMode: false }
: { movingNode: node, movingNodeOrigin: null },
),
// The node being placed/moved now lives inside the interaction scope
// (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter
// remains the single entry point: it drives the scope and still touches
// `movingNodeOrigin` / `placementDragMode` so cross-store subscribers that
// watch this store (community placement) keep firing on move start/end.
setMovingNode: (node) => {
const scope = useInteractionScope.getState()
if (node === null) {
scope.endIf((s) => s.kind === 'placing' || s.kind === 'moving')
// Preserve `movingNodeOrigin` across the clear so the non-owning
// side's effect cleanup — which fires after `setMovingNode(null)`
// propagates — can still read who finalised. The next non-null
// `setMovingNode` resets it. Always clear the press-drag flag.
set({ 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,
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',
cycleRotationAxis: () => {
const order = ['y', 'x', 'z'] as const
@@ -840,10 +910,6 @@ const useEditor = create<EditorState>()(
set({ rotationAxis: next })
return next
},
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
curvingFence: null,
setCurvingFence: (fence) => set({ curvingFence: fence }),
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
activePaintMaterial: null,
@@ -856,6 +922,19 @@ const useEditor = create<EditorState>()(
set((state) =>
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,
setPaintEraser: (eraser) => set({ paintEraser: eraser }),
primeMaterialPaintFromSelection: () => {
@@ -885,11 +964,10 @@ const useEditor = create<EditorState>()(
activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
}
},
hoveredPaintTarget: null,
setHoveredPaintTarget: (target) =>
set((state) =>
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
),
paintHover: null,
setPaintHover: (info) => set({ paintHover: info }),
canFindNode: false,
setCanFindNode: (canFind) => set({ canFindNode: canFind }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
guideUi: {},
@@ -924,8 +1002,6 @@ const useEditor = create<EditorState>()(
}),
spaces: {},
setSpaces: (spaces) => set({ spaces }),
editingHole: null,
setEditingHole: (hole) => set({ editingHole: hole }),
hoveredHole: null,
setHoveredHole: (hole) =>
set((state) =>
@@ -1007,8 +1083,48 @@ const useEditor = create<EditorState>()(
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
setGridSnapStep: (step) => set({ gridSnapStep: step }),
cycleGridSnapStep: () => {
const current = get().gridSnapStep
const index = GRID_SNAP_STEPS.indexOf(current)
const next = GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
set({ gridSnapStep: next })
return next
},
magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
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,
toggleReferenceFloor: () =>
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
@@ -1101,6 +1217,8 @@ const useEditor = create<EditorState>()(
floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep,
magneticSnap: state.magneticSnap,
snappingModeByContext: state.snappingModeByContext,
continuationByContext: state.continuationByContext,
showReferenceFloor: state.showReferenceFloor,
referenceFloorOffset: state.referenceFloorOffset,
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
@@ -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 {
boundaryReshapeScope,
clearCeilingSnapFeedback,
PolygonEditor,
type PolygonEditorPlanPointSnapContext,
resolveCeilingPlanPointSnap,
triggerSFX,
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -95,13 +97,19 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
const handleDragStateChange = useCallback(
(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
clearCeilingSnapFeedback()
}
setCeilingHandleHover(isDragging)
},
[setCeilingHandleHover],
[ceilingId, setCeilingHandleHover],
)
const handlePolygonEditorDragCommit = useCallback(() => {
@@ -126,7 +134,6 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
levelId: ceilingLevelId,
excludeId: ceilingId,
altKey: context.nativeEvent?.altKey === true,
shiftKey: context.nativeEvent?.shiftKey === true,
}).point,
[ceilingId, ceilingLevelId],
)
@@ -136,6 +143,9 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
clearCeilingSnapFeedback()
useLiveNodeOverrides.getState().clear(ceilingId)
useScene.getState().markDirty(ceilingId)
useInteractionScope
.getState()
.endIf((s) => s.kind === 'reshaping' && s.reshape === 'boundary')
ownsPolygonPreviewRef.current = false
if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) {
useViewer.getState().setHoveredId(null)
+2 -2
View File
@@ -79,6 +79,7 @@ function ceilingHandles(_node: CeilingNodeType): HandleDescriptor<CeilingNodeTyp
*/
export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
kind: 'ceiling',
snapProfile: 'structural',
schemaVersion: 1,
schema: CeilingNode,
category: 'structure',
@@ -155,8 +156,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
toolHints: [
{ key: 'Left click', label: 'Trace ceiling outline' },
{ key: 'Enter', label: 'Finish ceiling' },
{ key: 'Shift', label: 'Free outline' },
{ key: 'Enter', label: 'Finish ceiling', minDraftVertices: 3 },
{ key: 'Esc', label: 'Cancel' },
],
@@ -28,8 +28,9 @@ const ceilingSnapOptions = {
levelId: resolveLevelId(node, sceneNodes),
excludeId: node.id,
nodes: sceneNodes,
// Magnetic wall-snap/alignment gates on `isMagneticSnapActive()` (the
// `lines` mode), so no Shift bypass — Alt still force-skips alignment.
altKey: modifiers.altKey,
shiftKey: modifiers.shiftKey,
}).point
},
}
+6 -7
View File
@@ -16,6 +16,7 @@ import {
import {
CursorSphere,
consumePlacementDragRelease,
isMagneticSnapActive,
markToolCancelConsumed,
triggerSFX,
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.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) {
return snapScalar(value, useEditor.getState().gridSnapStep)
@@ -149,12 +150,10 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => {
if (isFloorplanSourcedEvent(event)) return
const bypassSnap = event.nativeEvent?.shiftKey === true
const localX = bypassSnap ? event.localPosition[0] : snap(event.localPosition[0])
const localZ = bypassSnap ? event.localPosition[2] : snap(event.localPosition[2])
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
if (
!bypassSnap &&
previousGridPosRef.current &&
(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
// vertices to other objects' anchors; fold the snap into the delta and
// publish a guide. Alt bypasses alignment; Shift bypasses all snap.
const bypass = event.nativeEvent?.altKey === true || bypassSnap
// publish a guide. Alignment follows the global magnetic snap mode.
const bypass = !isMagneticSnapActive()
if (!bypass && alignmentCandidates.length > 0) {
const result = resolveAlignment({
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 {
ActionButton,
ActionGroup,
holeEditScope,
PanelSection,
PanelWrapper,
SliderControl,
triggerSFX,
useEditingHole,
useEditor,
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
@@ -25,8 +28,7 @@ import { useCallback, useEffect, useRef } from 'react'
export function CeilingPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const editingHole = useEditingHole()
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
@@ -48,20 +50,26 @@ export function CeilingPanel() {
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
}, [setSelection, setEditingHole])
useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
}, [setSelection])
useEffect(() => {
if (!node) {
setEditingHole(null)
useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
}
}, [node, setEditingHole])
}, [node])
useEffect(() => {
return () => {
setEditingHole(null)
useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
}
}, [setEditingHole])
}, [])
const handleAddHole = useCallback(() => {
if (!(node && selectedId)) return
@@ -91,15 +99,17 @@ export function CeilingPanel() {
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }],
})
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
useInteractionScope
.getState()
.begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
}, [node, selectedId, handleUpdate])
const handleEditHole = useCallback(
(index: number) => {
if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index })
useInteractionScope.getState().begin(holeEditScope({ nodeId: selectedId, holeIndex: index }))
},
[selectedId, setEditingHole],
[selectedId],
)
const handleDeleteHole = useCallback(
@@ -114,10 +124,12 @@ export function CeilingPanel() {
const newMetadata = currentMetadata.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
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(() => {
@@ -213,7 +225,13 @@ export function CeilingPanel() {
<ActionButton
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
label="Done"
onClick={() => setEditingHole(null)}
onClick={() =>
useInteractionScope
.getState()
.endIf(
(scope) => scope.kind === 'reshaping' && scope.reshape === 'hole',
)
}
/>
) : isAutoHole ? (
<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,
clearCeilingSnapFeedback,
EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
resolveCeilingPlanPointSnap,
triggerSFX,
@@ -30,7 +33,6 @@ import { CeilingNode } from './schema'
* Multi-click polygon drawing at the ceiling height (2.52m default)
* with a vertical TSL-gradient connector + ground-shadow lines so the
* draft is visible against both the ceiling plane and the floor.
* Shift defeats the 15° angle snap during drag.
*/
const CEILING_HEIGHT = 2.52
@@ -65,7 +67,6 @@ export const CeilingTool: React.FC = () => {
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Clear preset-seeded defaults on deactivation so a later manual ceiling
// draw isn't built with a stale preset's parameters. Unmount-only.
@@ -73,6 +74,12 @@ export const CeilingTool: React.FC = () => {
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(
() =>
new BufferGeometry().setFromPoints([
@@ -93,38 +100,27 @@ export const CeilingTool: React.FC = () => {
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && gridCursorRef.current)) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const gridPosition: [number, number] = bypassSnap
? rawPoint
: [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)]
// Honour the active snapping mode: grid lattice + 15° angle lock are each
// gated on the mode (off / lines → free), like the slab tool.
const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
const gridPosition: [number, number] = [...snapPointToGrid(rawPoint, gridStep)]
setCursorPosition(gridPosition)
setLevelY(event.localPosition[1])
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
const gridY = event.localPosition[1] + GRID_OFFSET
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] =
bypassSnap || !lastPoint
? gridPosition
: [
...snapPointAlongAngleRay(
lastPoint,
rawPoint,
DEFAULT_ANGLE_STEP,
useEditor.getState().gridSnapStep,
),
]
isAngleSnapActive() && lastPoint
? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)]
: gridPosition
const displayPoint = resolveCeilingPlanPointSnap({
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true,
shiftKey: bypassSnap,
altKey: !isMagneticSnapActive(),
}).point
setSnappedCursorPosition(displayPoint)
if (
!bypassSnap &&
points.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
@@ -178,28 +174,12 @@ export const CeilingTool: React.FC = () => {
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:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
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
* `ColumnRenderer` (no system — column geometry is computed inline in
* the renderer). Inspector / move / floorplan still go through legacy
* paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx
* (their hardcoded `case 'column':` entries fire before the registry
* fallback).
* the renderer). Inspector / floorplan still go through legacy paths via
* panel-manager.tsx / floorplan-panel.tsx (their hardcoded `case 'column':`
* entries fire before the registry fallback).
*
* Capabilities: column doesn't declare `movable` because its move is
* bespoke (legacy MoveColumnTool snaps to slab + free placement on
* the X/Z plane with rotation).
* Capabilities: column declares the generic `movable` (translate on XZ
* with grid snap), so its 3D move runs through the shared
* `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
* `.default()` annotation on the schema (~60 fields).
*/
export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'column',
snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1,
schema: ColumnNode,
category: 'structure',
@@ -327,19 +331,29 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
selectable: { hitVolume: 'bbox' },
duplicable: 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),
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: {
footprint: (node) => {
const column = node as ColumnNodeType
const { halfX, halfZ } = columnFootprintHalf(column)
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
// expects the full Euler tuple.
rotation: [0, column.rotation, 0] as [number, number, number],
}
},
collides: true,
},
},
@@ -350,12 +364,6 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
kind: 'parametric',
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`
// ghost at the cursor (mirroring the shelf build tool) instead of the
// bare sphere the legacy editor-side `ColumnTool` showed. `ToolManager`'s
@@ -363,7 +371,6 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Place column' },
{ key: 'Shift', label: 'Free place' },
{ key: 'Esc', label: 'Cancel' },
],
floorplan: buildColumnFloorplan,
@@ -4,6 +4,7 @@ import {
type FloorplanAffordance,
useScene,
} from '@pascal-app/core'
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
// Floor minimums — mirror the 3D handles in `column/definition.ts` so a
// drag can't push a value past what the renderer accepts.
@@ -146,11 +147,13 @@ export const columnRotateAffordance: FloorplanAffordance<ColumnNode> = {
return {
affectedIds: [columnId],
apply({ planPoint }) {
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
let delta = currentAngle - initialAngle
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
apply({ planPoint, modifiers }) {
const delta = rotateAffordanceDelta({
center: [cx, cz],
initialAngle,
planPoint,
free: modifiers.shiftKey,
})
const newRotation = initialRotation - delta
lastRotation = newRotation
useScene.getState().updateNode(columnId, { rotation: newRotation })

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