Merge pull request #448 from pascalorg/feat/placement-interaction-overhaul
Placement & interaction overhaul: FSM scope spine, per-context snapping, 2D/floorplan perf
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
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(item.id)
|
||||
conflicts.push(node.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { CeilingNode, SlabNode, WallNode } from '../schema'
|
||||
import { planAutoCeilingsForLevel } from './space-detection'
|
||||
import { planAutoCeilingsForLevel, planAutoSlabsForLevel } from './space-detection'
|
||||
|
||||
const square: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
@@ -90,3 +90,26 @@ describe('planAutoCeilingsForLevel', () => {
|
||||
expect(plan.update).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planAutoSlabsForLevel', () => {
|
||||
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
|
||||
// Two rooms with identical polygon signatures previously collided in a
|
||||
// signature-keyed Map, so one detected room never matched an existing slab
|
||||
// and churned (delete + recreate) on every pass.
|
||||
const slabA = slab(0.05)
|
||||
const slabB = slab(0.05)
|
||||
|
||||
const plan = planAutoSlabsForLevel([roomPolygon(), roomPolygon()], [slabA, slabB])
|
||||
|
||||
expect(plan.create).toHaveLength(0)
|
||||
expect(plan.delete).toHaveLength(0)
|
||||
expect(plan.update).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('deletes an extra auto-slab when only one identical room is detected', () => {
|
||||
const plan = planAutoSlabsForLevel([roomPolygon()], [slab(0.05), slab(0.05)])
|
||||
|
||||
expect(plan.create).toHaveLength(0)
|
||||
expect(plan.delete).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -595,43 +595,25 @@ function levelWallSnapshot(walls: WallNode[]) {
|
||||
return walls.map(wallGeometrySignature).sort().join('||')
|
||||
}
|
||||
|
||||
function slabGeometrySignature(slab: SlabNodeType) {
|
||||
const polygon = slab.polygon
|
||||
.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`)
|
||||
.join(';')
|
||||
const holes = (slab.holes ?? [])
|
||||
.map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';'))
|
||||
.join('/')
|
||||
|
||||
return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join(
|
||||
'|',
|
||||
)
|
||||
}
|
||||
|
||||
function levelSlabSnapshot(slabs: SlabNodeType[]) {
|
||||
return slabs.map(slabGeometrySignature).sort().join('||')
|
||||
}
|
||||
|
||||
// Trigger signature is wall-only on purpose: re-detection should fire on a
|
||||
// genuine remodel (wall geometry change), never when an auto-slab is edited or
|
||||
// deleted. Hashing slabs here created a feedback loop where deleting an
|
||||
// auto-slab re-fired detection and recreated it.
|
||||
function levelStructureSnapshots(nodes: Record<string, any>) {
|
||||
const byLevel = new Map<string, { walls: WallNode[]; slabs: SlabNodeType[] }>()
|
||||
const getEntry = (levelId: string) => {
|
||||
const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] }
|
||||
byLevel.set(levelId, entry)
|
||||
return entry
|
||||
}
|
||||
const byLevel = new Map<string, WallNode[]>()
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
|
||||
if ((node as any).type === 'wall') {
|
||||
getEntry((node as any).parentId).walls.push(node as WallNode)
|
||||
} else if ((node as any).type === 'slab') {
|
||||
getEntry((node as any).parentId).slabs.push(SlabNode.parse(node))
|
||||
}
|
||||
if ((node as any).type !== 'wall') continue
|
||||
const levelId = (node as any).parentId as string
|
||||
const walls = byLevel.get(levelId) ?? []
|
||||
walls.push(node as WallNode)
|
||||
byLevel.set(levelId, walls)
|
||||
}
|
||||
|
||||
const snapshots = new Map<string, string>()
|
||||
for (const [levelId, entry] of byLevel.entries()) {
|
||||
snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`)
|
||||
for (const [levelId, walls] of byLevel.entries()) {
|
||||
snapshots.set(levelId, levelWallSnapshot(walls))
|
||||
}
|
||||
|
||||
return snapshots
|
||||
@@ -692,13 +674,15 @@ export function planAutoSlabsForLevel(
|
||||
const matchedDetectedIdx = new Set<number>()
|
||||
const updatesById = new Map<string, [number, number][]>()
|
||||
|
||||
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
|
||||
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
|
||||
for (const entry of existingAutoMeta) {
|
||||
autoBySignature.set(entry.sig, entry)
|
||||
const bucket = autoBySignature.get(entry.sig) ?? []
|
||||
bucket.push(entry)
|
||||
autoBySignature.set(entry.sig, bucket)
|
||||
}
|
||||
|
||||
detected.forEach((room, index) => {
|
||||
const existing = autoBySignature.get(room.sig)
|
||||
const existing = autoBySignature.get(room.sig)?.shift()
|
||||
if (!existing) return
|
||||
|
||||
matchedDetectedIdx.add(index)
|
||||
@@ -875,13 +859,15 @@ export function planAutoCeilingsForLevel(
|
||||
const matchedDetectedIdx = new Set<number>()
|
||||
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
|
||||
|
||||
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
|
||||
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
|
||||
for (const entry of existingAutoMeta) {
|
||||
autoBySignature.set(entry.sig, entry)
|
||||
const bucket = autoBySignature.get(entry.sig) ?? []
|
||||
bucket.push(entry)
|
||||
autoBySignature.set(entry.sig, bucket)
|
||||
}
|
||||
|
||||
detected.forEach((room, index) => {
|
||||
const existing = autoBySignature.get(room.sig)
|
||||
const existing = autoBySignature.get(room.sig)?.shift()
|
||||
if (!existing) return
|
||||
|
||||
matchedDetectedIdx.add(index)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,6 +112,18 @@ export function getTopSurfaceHeight(host: AnyNode): number | null {
|
||||
return typeof height === 'function' ? height(host) : height
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `host` can receive a surface-resting (top-stacked) child. A
|
||||
* ceiling-mounted item hangs from the ceiling, so its visible "top" is not a
|
||||
* usable resting surface — nothing should stack on a ceiling fan. The check
|
||||
* reads the instance-level `asset.attachTo` (not the host KIND, which is shared
|
||||
* across all items) so a single gate covers every interaction path.
|
||||
*/
|
||||
export function canHostOnTop(host: AnyNode): boolean {
|
||||
const attachTo = (host as { asset?: { attachTo?: string } }).asset?.attachTo
|
||||
return attachTo !== 'ceiling'
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure host-discovery helper. Given a list of candidate hosts (already
|
||||
* narrowed by spatial query) and a point, returns the first whose
|
||||
@@ -129,10 +141,7 @@ export function pickHost(args: {
|
||||
const def = nodeRegistry.get(host.type)
|
||||
const hostable = def?.capabilities.hostable
|
||||
if (!hostable) continue
|
||||
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) {
|
||||
// capability declares specific parents; verify the placed kind's own def
|
||||
// also permits this host kind.
|
||||
}
|
||||
if (!canHostOnTop(host)) continue
|
||||
if (args.hitTest && !args.hitTest(host, args.point)) continue
|
||||
return host
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export {
|
||||
type AttachError,
|
||||
type AttachResult,
|
||||
canAttach,
|
||||
canHostOnTop,
|
||||
clampYToHostTop,
|
||||
getSurface,
|
||||
getTopSurfaceHeight,
|
||||
|
||||
@@ -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.3–2m 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
@@ -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).
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
// Floor grid depth-tests against the scene (ground occludes a sub-floor
|
||||
// lattice); the wall grid ignores depth so it stays visible through the wall
|
||||
// when the opening is being handled from the opposite side.
|
||||
if (material.depthTest === onWall) {
|
||||
material.depthTest = !onWall
|
||||
material.needsUpdate = true
|
||||
}
|
||||
|
||||
// The grid is a placement aid: a tight cursor patch (no always-on baseline)
|
||||
// shown whenever the active context is in grid-snap mode — ANY armed
|
||||
// draft/build tool (wall / slab / fence / ceiling / zone / column / MEP / …),
|
||||
// a node move, or a reshape — and hidden in select/idle, paint, and non-grid
|
||||
// (lines/off) modes. `isGridSnapActive()` already derives the snap context
|
||||
// from the interaction scope OR the armed build tool and is true only when
|
||||
// that context resolves to grid, so it IS the gate. (Previously this also
|
||||
// required a ghost in flight, so a merely-armed draft tool showed nothing.)
|
||||
const snapPatchVisible = isGridSnapActive()
|
||||
revealRadiusUniform.value = PLACEMENT_REVEAL_RADIUS
|
||||
baseAlphaUniform.value = 0
|
||||
cellSizeUniform.value = useEditor.getState().gridSnapStep
|
||||
patchAlphaUniform.value = 1.5
|
||||
gridRef.current.visible = snapPatchVisible
|
||||
})
|
||||
|
||||
// Pass the geometry as a prop instead of a JSX child so the mesh
|
||||
// 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() {
|
||||
|
||||
@@ -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'
|
||||
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,
|
||||
// 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,
|
||||
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 = ' | ||||