feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf
Implements plans/editor-placement-interaction-overhaul.md: an authoritative interaction-scope state machine plus the catalogued placement/interaction fixes, and split-view floor-plan performance. - Interaction-scope spine (lib/interaction/* + store/use-interaction-scope), driven from central useEditor setters; overlay scoping (zone labels, context badges, floating action menu) reads resolveOverlayPolicy. - Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity, auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset, per-frame jank. - Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier model (Shift=cycle, Alt=free place, Ctrl=grid step). - Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle rig hides during a whole-node move; rotate gizmo advertises Shift=free rotation in the HUD and hides the move cross while rotating. - Floor-plan perf: pause live reactivity while in 3D-only view; per-node geometry cache so only changed nodes rebuild on a drag; hoist wall miters to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b2f1a8432e
commit
f773e6b8c5
@@ -4,7 +4,7 @@ import { nodeRegistry } from '@pascal-app/core'
|
|||||||
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
|
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -152,15 +152,19 @@ function activateRoofFeatureTool(kind: string): void {
|
|||||||
* with the kind's own `def.defaults()`. The "Painting" type swaps in the
|
* with the kind's own `def.defaults()`. The "Painting" type swaps in the
|
||||||
* material-paint panel.
|
* material-paint panel.
|
||||||
*/
|
*/
|
||||||
|
// MEP tool kinds that, when active, mean the MEP group tile (and its sub-grid)
|
||||||
|
// is what the user is working in.
|
||||||
|
const MEP_TOOL_KINDS = new Set<string>([
|
||||||
|
...MEP_ITEMS.map((item) => item.kind),
|
||||||
|
'duct-fitting',
|
||||||
|
'pipe-fitting',
|
||||||
|
])
|
||||||
|
|
||||||
export function BuildTab() {
|
export function BuildTab() {
|
||||||
const activeTool = useEditor((s) => s.tool)
|
const activeTool = useEditor((s) => s.tool)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const follow = useLiquidLineToolOptions((s) => s.follow)
|
const follow = useLiquidLineToolOptions((s) => s.follow)
|
||||||
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
|
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
|
||||||
// Which build tile's panel is showing. Roof (Features) and MEP (its tool
|
|
||||||
// sub-grid) are the tiles with a panel; others arm a tool and show nothing
|
|
||||||
// below.
|
|
||||||
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// The fitting / follow tools are armed from a segment's panel, not a grid
|
// The fitting / follow tools are armed from a segment's panel, not a grid
|
||||||
// tile — keep the segment tile lit so the panel (and the way back) stays
|
// tile — keep the segment tile lit so the panel (and the way back) stays
|
||||||
@@ -200,8 +204,23 @@ export function BuildTab() {
|
|||||||
return features
|
return features
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isTypeActive = (type: BuildType) =>
|
// Tile highlight derives from the single source of truth (the active tool /
|
||||||
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id
|
// mode), never a separate local selection — so keyboard shortcuts and panel
|
||||||
|
// clicks always agree on which tile is lit.
|
||||||
|
// The roof Features sub-grid arms roof-accessory tools (skylight, chimney,
|
||||||
|
// …); keep the Roof tile lit (and its panel open) while any of them is the
|
||||||
|
// active tool, the same way MEP stays lit for its sub-grid tools.
|
||||||
|
const isRoofFeatureActive =
|
||||||
|
mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool)
|
||||||
|
const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)
|
||||||
|
|
||||||
|
const isTypeActive = (type: BuildType) => {
|
||||||
|
if (type.mode === 'material-paint') return mode === 'material-paint'
|
||||||
|
if (type.id === 'mep') return isMepActive
|
||||||
|
if (type.id === 'roof')
|
||||||
|
return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive)
|
||||||
|
return mode === 'build' && activeTool === type.kind
|
||||||
|
}
|
||||||
|
|
||||||
const handleTypeClick = useCallback((type: BuildType) => {
|
const handleTypeClick = useCallback((type: BuildType) => {
|
||||||
if (type.mode === 'material-paint') {
|
if (type.mode === 'material-paint') {
|
||||||
@@ -213,15 +232,18 @@ export function BuildTab() {
|
|||||||
} else if (type.kind) {
|
} else if (type.kind) {
|
||||||
activateBuildTool(type.kind)
|
activateBuildTool(type.kind)
|
||||||
}
|
}
|
||||||
setSelectedTypeId(type.id)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// On open, land on the first build tool — parity with the community Build
|
// On open, land on the first build tool — parity with the community Build
|
||||||
// sidebar, so switching to Build immediately arms a usable tool.
|
// sidebar, so switching to Build immediately arms a usable tool. Skip when a
|
||||||
|
// build tool is already active (e.g. the B shortcut armed one before this
|
||||||
|
// panel mounted): the active tool is the source of truth, not this default.
|
||||||
const didInitRef = useRef(false)
|
const didInitRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (didInitRef.current) return
|
if (didInitRef.current) return
|
||||||
didInitRef.current = true
|
didInitRef.current = true
|
||||||
|
const ed = useEditor.getState()
|
||||||
|
if (ed.mode === 'build' && ed.tool) return
|
||||||
const firstType = BUILD_TYPES.find((t) => t.kind)
|
const firstType = BUILD_TYPES.find((t) => t.kind)
|
||||||
if (firstType) handleTypeClick(firstType)
|
if (firstType) handleTypeClick(firstType)
|
||||||
}, [handleTypeClick])
|
}, [handleTypeClick])
|
||||||
@@ -274,7 +296,9 @@ export function BuildTab() {
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<MaterialPaintPanel />
|
<MaterialPaintPanel />
|
||||||
</div>
|
</div>
|
||||||
) : selectedTypeId === 'roof' && roofFeatures.length > 0 ? (
|
) : mode === 'build' &&
|
||||||
|
(activeTool === 'roof' || isRoofFeatureActive) &&
|
||||||
|
roofFeatures.length > 0 ? (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
|
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||||
@@ -319,7 +343,7 @@ export function BuildTab() {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
) : selectedTypeId === 'mep' ? (
|
) : isMepActive ? (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div>
|
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div>
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { CeilingNode, SlabNode, WallNode } from '../schema'
|
import { CeilingNode, SlabNode, WallNode } from '../schema'
|
||||||
import { planAutoCeilingsForLevel } from './space-detection'
|
import { planAutoCeilingsForLevel, planAutoSlabsForLevel } from './space-detection'
|
||||||
|
|
||||||
const square: Array<[number, number]> = [
|
const square: Array<[number, number]> = [
|
||||||
[0, 0],
|
[0, 0],
|
||||||
@@ -90,3 +90,26 @@ describe('planAutoCeilingsForLevel', () => {
|
|||||||
expect(plan.update).toHaveLength(0)
|
expect(plan.update).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('planAutoSlabsForLevel', () => {
|
||||||
|
test('matches two identical rooms to their own existing auto-slabs without churn', () => {
|
||||||
|
// Two rooms with identical polygon signatures previously collided in a
|
||||||
|
// signature-keyed Map, so one detected room never matched an existing slab
|
||||||
|
// and churned (delete + recreate) on every pass.
|
||||||
|
const slabA = slab(0.05)
|
||||||
|
const slabB = slab(0.05)
|
||||||
|
|
||||||
|
const plan = planAutoSlabsForLevel([roomPolygon(), roomPolygon()], [slabA, slabB])
|
||||||
|
|
||||||
|
expect(plan.create).toHaveLength(0)
|
||||||
|
expect(plan.delete).toHaveLength(0)
|
||||||
|
expect(plan.update).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes an extra auto-slab when only one identical room is detected', () => {
|
||||||
|
const plan = planAutoSlabsForLevel([roomPolygon()], [slab(0.05), slab(0.05)])
|
||||||
|
|
||||||
|
expect(plan.create).toHaveLength(0)
|
||||||
|
expect(plan.delete).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -595,43 +595,25 @@ function levelWallSnapshot(walls: WallNode[]) {
|
|||||||
return walls.map(wallGeometrySignature).sort().join('||')
|
return walls.map(wallGeometrySignature).sort().join('||')
|
||||||
}
|
}
|
||||||
|
|
||||||
function slabGeometrySignature(slab: SlabNodeType) {
|
// Trigger signature is wall-only on purpose: re-detection should fire on a
|
||||||
const polygon = slab.polygon
|
// genuine remodel (wall geometry change), never when an auto-slab is edited or
|
||||||
.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`)
|
// deleted. Hashing slabs here created a feedback loop where deleting an
|
||||||
.join(';')
|
// auto-slab re-fired detection and recreated it.
|
||||||
const holes = (slab.holes ?? [])
|
|
||||||
.map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';'))
|
|
||||||
.join('/')
|
|
||||||
|
|
||||||
return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join(
|
|
||||||
'|',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function levelSlabSnapshot(slabs: SlabNodeType[]) {
|
|
||||||
return slabs.map(slabGeometrySignature).sort().join('||')
|
|
||||||
}
|
|
||||||
|
|
||||||
function levelStructureSnapshots(nodes: Record<string, any>) {
|
function levelStructureSnapshots(nodes: Record<string, any>) {
|
||||||
const byLevel = new Map<string, { walls: WallNode[]; slabs: SlabNodeType[] }>()
|
const byLevel = new Map<string, WallNode[]>()
|
||||||
const getEntry = (levelId: string) => {
|
|
||||||
const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] }
|
|
||||||
byLevel.set(levelId, entry)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const node of Object.values(nodes)) {
|
for (const node of Object.values(nodes)) {
|
||||||
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
|
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
|
||||||
if ((node as any).type === 'wall') {
|
if ((node as any).type !== 'wall') continue
|
||||||
getEntry((node as any).parentId).walls.push(node as WallNode)
|
const levelId = (node as any).parentId as string
|
||||||
} else if ((node as any).type === 'slab') {
|
const walls = byLevel.get(levelId) ?? []
|
||||||
getEntry((node as any).parentId).slabs.push(SlabNode.parse(node))
|
walls.push(node as WallNode)
|
||||||
}
|
byLevel.set(levelId, walls)
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshots = new Map<string, string>()
|
const snapshots = new Map<string, string>()
|
||||||
for (const [levelId, entry] of byLevel.entries()) {
|
for (const [levelId, walls] of byLevel.entries()) {
|
||||||
snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`)
|
snapshots.set(levelId, levelWallSnapshot(walls))
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshots
|
return snapshots
|
||||||
@@ -692,13 +674,15 @@ export function planAutoSlabsForLevel(
|
|||||||
const matchedDetectedIdx = new Set<number>()
|
const matchedDetectedIdx = new Set<number>()
|
||||||
const updatesById = new Map<string, [number, number][]>()
|
const updatesById = new Map<string, [number, number][]>()
|
||||||
|
|
||||||
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
|
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
|
||||||
for (const entry of existingAutoMeta) {
|
for (const entry of existingAutoMeta) {
|
||||||
autoBySignature.set(entry.sig, entry)
|
const bucket = autoBySignature.get(entry.sig) ?? []
|
||||||
|
bucket.push(entry)
|
||||||
|
autoBySignature.set(entry.sig, bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
detected.forEach((room, index) => {
|
detected.forEach((room, index) => {
|
||||||
const existing = autoBySignature.get(room.sig)
|
const existing = autoBySignature.get(room.sig)?.shift()
|
||||||
if (!existing) return
|
if (!existing) return
|
||||||
|
|
||||||
matchedDetectedIdx.add(index)
|
matchedDetectedIdx.add(index)
|
||||||
@@ -875,13 +859,15 @@ export function planAutoCeilingsForLevel(
|
|||||||
const matchedDetectedIdx = new Set<number>()
|
const matchedDetectedIdx = new Set<number>()
|
||||||
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
|
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
|
||||||
|
|
||||||
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
|
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
|
||||||
for (const entry of existingAutoMeta) {
|
for (const entry of existingAutoMeta) {
|
||||||
autoBySignature.set(entry.sig, entry)
|
const bucket = autoBySignature.get(entry.sig) ?? []
|
||||||
|
bucket.push(entry)
|
||||||
|
autoBySignature.set(entry.sig, bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
detected.forEach((room, index) => {
|
detected.forEach((room, index) => {
|
||||||
const existing = autoBySignature.get(room.sig)
|
const existing = autoBySignature.get(room.sig)?.shift()
|
||||||
if (!existing) return
|
if (!existing) return
|
||||||
|
|
||||||
matchedDetectedIdx.add(index)
|
matchedDetectedIdx.add(index)
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ import type { CloneNodesIntoOptions, Subtree } from './subtree'
|
|||||||
// door cutouts read parent wall — use `ctx` to resolve those references
|
// door cutouts read parent wall — use `ctx` to resolve those references
|
||||||
// without importing `useScene`. Builders stay pure and unit-testable.
|
// without importing `useScene`. Builders stay pure and unit-testable.
|
||||||
//
|
//
|
||||||
// Future extension: `levelData?: { miters?: ... }` for level-scoped batch
|
// `levelData` carries level-scoped batch data (wall mitering across an
|
||||||
// data (wall mitering across an entire level). Decided alongside the wall
|
// entire level) from registry dispatchers into pure builders.
|
||||||
// migration off its dedicated system (Phase 3+).
|
|
||||||
|
|
||||||
export type GeometryContext = {
|
export type GeometryContext = {
|
||||||
/** Look up any node by ID. Returns undefined if the node doesn't exist. */
|
/** Look up any node by ID. Returns undefined if the node doesn't exist. */
|
||||||
@@ -30,18 +29,16 @@ export type GeometryContext = {
|
|||||||
parent: AnyNode | null
|
parent: AnyNode | null
|
||||||
/**
|
/**
|
||||||
* Pre-computed level-batch data, populated by the dispatcher when the
|
* Pre-computed level-batch data, populated by the dispatcher when the
|
||||||
* kind declares `def.computeLevelData`. Shared across every
|
* kind declares `def.computeLevelData` (3D) or
|
||||||
* `def.geometry(node, ctx)` call in the same level batch within a
|
* `def.computeFloorplanLevelData` (2D). Shared across every builder call
|
||||||
* single frame, so kinds whose geometry depends on cross-sibling
|
* in the same level batch within a single frame/render pass, so kinds
|
||||||
* data (wall mitering, gradient sky uniforms across a zone, etc.)
|
* whose geometry depends on cross-sibling data (wall mitering, gradient
|
||||||
* don't pay an O(N²) recomputation cost.
|
* sky uniforms across a zone, etc.) don't pay an O(N²) recomputation cost.
|
||||||
*
|
*
|
||||||
* Typed as `unknown` at the framework boundary — kinds cast to their
|
* Typed as `unknown` at the framework boundary — kinds cast to their
|
||||||
* own `LevelData` shape inside `def.geometry` (the same kind owns
|
* own `LevelData` shape inside `def.geometry` / `def.floorplan` (the
|
||||||
* both the `computeLevelData` return shape and the `geometry`
|
* same kind owns both the compute hook's return shape and the builder
|
||||||
* consumer, so the cast is internal). Only populated for `def.
|
* consumer, so the cast is internal).
|
||||||
* geometry` calls today; not used by `def.floorplan` (which already
|
|
||||||
* has cheap access to siblings through `ctx.siblings`).
|
|
||||||
*/
|
*/
|
||||||
levelData?: unknown
|
levelData?: unknown
|
||||||
/**
|
/**
|
||||||
@@ -820,6 +817,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
* runs once even when many walls are dirty in the same frame.
|
* runs once even when many walls are dirty in the same frame.
|
||||||
*/
|
*/
|
||||||
computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown
|
computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown
|
||||||
|
/**
|
||||||
|
* Floor-plan level-batch precompute hook. The floor-plan layer calls this
|
||||||
|
* once per level per render pass, de-duplicated by kind, before the
|
||||||
|
* per-node `def.floorplan` calls. The result lands in `ctx.levelData` for
|
||||||
|
* every node of this kind in the level.
|
||||||
|
*
|
||||||
|
* Used to hoist cross-sibling floor-plan work that would otherwise be
|
||||||
|
* O(N²) when rebuilding every node in a kind — e.g. wall mitering. `nodes`
|
||||||
|
* is the live-merged scene snapshot; `siblings` is every node of this kind
|
||||||
|
* in the level, also live-merged.
|
||||||
|
*/
|
||||||
|
computeFloorplanLevelData?: (args: {
|
||||||
|
siblings: ReadonlyArray<z.infer<S>>
|
||||||
|
nodes: Record<string, AnyNode>
|
||||||
|
}) => unknown
|
||||||
/**
|
/**
|
||||||
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
|
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
|
||||||
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
|
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
|
||||||
@@ -877,6 +889,12 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
* unset and rely on the generic overlay path.
|
* unset and rely on the generic overlay path.
|
||||||
*/
|
*/
|
||||||
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
|
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
|
||||||
|
/**
|
||||||
|
* Geometry reads sibling/parent/child nodes (e.g. wall miters, opening
|
||||||
|
* dimensions); the floor-plan layer must rebuild it whenever a
|
||||||
|
* sibling-affecting node is being dragged live.
|
||||||
|
*/
|
||||||
|
floorplanDependsOnSiblings?: boolean
|
||||||
/**
|
/**
|
||||||
* Optional hook letting a kind project the `useLiveNodeOverrides` map
|
* Optional hook letting a kind project the `useLiveNodeOverrides` map
|
||||||
* into a fresh `nodes` snapshot before its `def.floorplan` builder
|
* into a fresh `nodes` snapshot before its `def.floorplan` builder
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { AnyNodeDefinition, Capabilities, SceneApi } from '../registry/type
|
|||||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
import {
|
import {
|
||||||
canAttach,
|
canAttach,
|
||||||
|
canHostOnTop,
|
||||||
clampYToHostTop,
|
clampYToHostTop,
|
||||||
getSurface,
|
getSurface,
|
||||||
getTopSurfaceHeight,
|
getTopSurfaceHeight,
|
||||||
@@ -14,6 +15,16 @@ import {
|
|||||||
|
|
||||||
const id = (s: string) => s as AnyNodeId
|
const id = (s: string) => s as AnyNodeId
|
||||||
|
|
||||||
|
function makeItem(idStr: string, attachTo?: 'wall' | 'wall-side' | 'ceiling'): AnyNode {
|
||||||
|
return {
|
||||||
|
id: id(idStr),
|
||||||
|
type: 'item',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
asset: attachTo ? { attachTo } : {},
|
||||||
|
} as unknown as AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
function makeDef(
|
function makeDef(
|
||||||
kind: string,
|
kind: string,
|
||||||
capabilities: Capabilities = {},
|
capabilities: Capabilities = {},
|
||||||
@@ -233,4 +244,30 @@ describe('pickHost', () => {
|
|||||||
})
|
})
|
||||||
expect(picked?.id).toBe(id('s2'))
|
expect(picked?.id).toBe(id('s2'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('excludes ceiling-mounted hosts (ceiling fan cannot be a top surface)', () => {
|
||||||
|
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
|
||||||
|
const candidates = [makeItem('fan', 'ceiling'), makeItem('table')]
|
||||||
|
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
|
||||||
|
expect(picked?.id).toBe(id('table'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps wall-mounted hosts (wall shelf still hosts)', () => {
|
||||||
|
registerNode(makeDef('item', { hostable: { parents: ['*'] } }))
|
||||||
|
const candidates = [makeItem('shelf', 'wall')]
|
||||||
|
const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' })
|
||||||
|
expect(picked?.id).toBe(id('shelf'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('canHostOnTop', () => {
|
||||||
|
test('rejects ceiling-attachTo hosts', () => {
|
||||||
|
expect(canHostOnTop(makeItem('fan', 'ceiling'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts wall / wall-side / floor (undefined) hosts', () => {
|
||||||
|
expect(canHostOnTop(makeItem('shelf', 'wall'))).toBe(true)
|
||||||
|
expect(canHostOnTop(makeItem('sconce', 'wall-side'))).toBe(true)
|
||||||
|
expect(canHostOnTop(makeItem('table'))).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -112,6 +112,18 @@ export function getTopSurfaceHeight(host: AnyNode): number | null {
|
|||||||
return typeof height === 'function' ? height(host) : height
|
return typeof height === 'function' ? height(host) : height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `host` can receive a surface-resting (top-stacked) child. A
|
||||||
|
* ceiling-mounted item hangs from the ceiling, so its visible "top" is not a
|
||||||
|
* usable resting surface — nothing should stack on a ceiling fan. The check
|
||||||
|
* reads the instance-level `asset.attachTo` (not the host KIND, which is shared
|
||||||
|
* across all items) so a single gate covers every interaction path.
|
||||||
|
*/
|
||||||
|
export function canHostOnTop(host: AnyNode): boolean {
|
||||||
|
const attachTo = (host as { asset?: { attachTo?: string } }).asset?.attachTo
|
||||||
|
return attachTo !== 'ceiling'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure host-discovery helper. Given a list of candidate hosts (already
|
* Pure host-discovery helper. Given a list of candidate hosts (already
|
||||||
* narrowed by spatial query) and a point, returns the first whose
|
* narrowed by spatial query) and a point, returns the first whose
|
||||||
@@ -129,10 +141,7 @@ export function pickHost(args: {
|
|||||||
const def = nodeRegistry.get(host.type)
|
const def = nodeRegistry.get(host.type)
|
||||||
const hostable = def?.capabilities.hostable
|
const hostable = def?.capabilities.hostable
|
||||||
if (!hostable) continue
|
if (!hostable) continue
|
||||||
if (hostable.parents.length > 0 && !hostable.parents.includes('*')) {
|
if (!canHostOnTop(host)) continue
|
||||||
// capability declares specific parents; verify the placed kind's own def
|
|
||||||
// also permits this host kind.
|
|
||||||
}
|
|
||||||
if (args.hitTest && !args.hitTest(host, args.point)) continue
|
if (args.hitTest && !args.hitTest(host, args.point)) continue
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export {
|
|||||||
type AttachError,
|
type AttachError,
|
||||||
type AttachResult,
|
type AttachResult,
|
||||||
canAttach,
|
canAttach,
|
||||||
|
canHostOnTop,
|
||||||
clampYToHostTop,
|
clampYToHostTop,
|
||||||
getSurface,
|
getSurface,
|
||||||
getTopSurfaceHeight,
|
getTopSurfaceHeight,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function FloorplanRegistryActionMenu() {
|
|||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
||||||
const movingNode = useEditor((s) => s.movingNode)
|
const movingNode = useEditor((s) => s.movingNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
||||||
// Gate on floorplan hover so this 2D menu never coexists with the 3D
|
// Gate on floorplan hover so this 2D menu never coexists with the 3D
|
||||||
// FloatingActionMenu in split view — that menu hides while the floorplan
|
// FloatingActionMenu in split view — that menu hides while the floorplan
|
||||||
// is hovered, so this one must only show then. Mirrors the legacy
|
// is hovered, so this one must only show then. Mirrors the legacy
|
||||||
@@ -141,6 +142,11 @@ export function FloorplanRegistryActionMenu() {
|
|||||||
const handleMove = () => {
|
const handleMove = () => {
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
setMovingNode(node as never)
|
setMovingNode(node as never)
|
||||||
|
// 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
|
||||||
|
// Mark the origin (after `setMovingNode`, which resets it to null) so
|
||||||
|
// `ToolManager` keeps the 3D affordance mover from also adopting the node
|
||||||
|
// and reverting it on unmount. Mirrors the orange move-dot path.
|
||||||
|
setMovingNodeOrigin('2d')
|
||||||
// Match the legacy 3D `floating-action-menu`: clear selection so
|
// Match the legacy 3D `floating-action-menu`: clear selection so
|
||||||
// selection-gated affordances unmount during the drag. Specifically
|
// selection-gated affordances unmount during the drag. Specifically
|
||||||
// the slab / ceiling boundary editor (`ToolManager` shows it when
|
// the slab / ceiling boundary editor (`ToolManager` shows it when
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
type GeometryContext,
|
type GeometryContext,
|
||||||
isRegistryMovable,
|
isRegistryMovable,
|
||||||
kindsWithFloorplanScope,
|
kindsWithFloorplanScope,
|
||||||
|
type LiveNodeOverrides,
|
||||||
|
type LiveTransform,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
pauseSceneHistory,
|
pauseSceneHistory,
|
||||||
resolveBuildingForLevel,
|
resolveBuildingForLevel,
|
||||||
@@ -122,6 +124,46 @@ type RotationOverlayState = {
|
|||||||
sweep: number
|
sweep: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FloorplanEntry = {
|
||||||
|
id: AnyNodeId
|
||||||
|
node: AnyNode
|
||||||
|
base: FloorplanGeometry | null
|
||||||
|
overlay: FloorplanGeometry | null
|
||||||
|
selected: boolean
|
||||||
|
highlighted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeDeps = {
|
||||||
|
node: AnyNode
|
||||||
|
live: LiveTransform | undefined
|
||||||
|
selected: boolean
|
||||||
|
highlighted: boolean
|
||||||
|
hovered: boolean
|
||||||
|
moving: boolean
|
||||||
|
palette: FloorplanPalette | undefined
|
||||||
|
siblingEpoch: number
|
||||||
|
committedNodes: Record<string, AnyNode> | null
|
||||||
|
interactiveElevators: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
deps: NodeDeps
|
||||||
|
base: FloorplanGeometry | null
|
||||||
|
overlay: FloorplanGeometry | null
|
||||||
|
node: AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type FloorplanContextOverrides = {
|
||||||
|
children: AnyNode[]
|
||||||
|
siblings: AnyNode[]
|
||||||
|
parent: AnyNode | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type FloorplanLevelDataHook = (args: {
|
||||||
|
siblings: ReadonlyArray<AnyNode>
|
||||||
|
nodes: Record<string, AnyNode>
|
||||||
|
}) => unknown
|
||||||
|
|
||||||
function snapshotNode(node: AnyNode): NodeSnapshot {
|
function snapshotNode(node: AnyNode): NodeSnapshot {
|
||||||
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
||||||
// deep-cloned to detach from the live store reference.
|
// deep-cloned to detach from the live store reference.
|
||||||
@@ -137,6 +179,16 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
|||||||
return snapshots.map((s) => ({ id: s.id, data: s.data }))
|
return snapshots.map((s) => ({ id: s.id, data: s.data }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable empty sentinels. While the floor plan is hidden (3D-only view) the
|
||||||
|
// live-* selectors return these instead of the real maps, so the per-pointer
|
||||||
|
// drag publishes (usePlacementCoordinator → useLiveTransforms / the rotate
|
||||||
|
// gizmo → useLiveNodeOverrides) no longer re-render this layer and its hundreds
|
||||||
|
// of geometry children. The same reference each call keeps zustand from
|
||||||
|
// detecting a change; committed scene edits still flow through `useScene`, so
|
||||||
|
// the plan is current the instant the view is shown again.
|
||||||
|
const EMPTY_LIVE_TRANSFORMS: Map<string, LiveTransform> = new Map()
|
||||||
|
const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
|
||||||
|
|
||||||
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||||
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
||||||
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
||||||
@@ -191,6 +243,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const renderCtx = useFloorplanRender()
|
const renderCtx = useFloorplanRender()
|
||||||
const movingNode = useEditor((s) => s.movingNode)
|
const movingNode = useEditor((s) => s.movingNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
||||||
// Door / window placement (both build and move) needs the SVG's
|
// Door / window placement (both build and move) needs the SVG's
|
||||||
// background click handler to run — it finds the closest wall via
|
// background click handler to run — it finds the closest wall via
|
||||||
// `findClosestWallPoint` and emits `wall:click` for the door / window
|
// `findClosestWallPoint` and emits `wall:click` for the door / window
|
||||||
@@ -215,17 +268,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
structureLayer !== 'zones' &&
|
structureLayer !== 'zones' &&
|
||||||
!movingNode &&
|
!movingNode &&
|
||||||
!movingFenceEndpoint
|
!movingFenceEndpoint
|
||||||
|
// While the floor plan is not on screen (pure 3D view) it must not react to
|
||||||
|
// the per-pointer drag publishes below — re-rendering this layer + its
|
||||||
|
// hundreds of geometry children every move is what tanks 3D-drag framerate
|
||||||
|
// even though nothing 2D is visible. Gating the live-* subscriptions freezes
|
||||||
|
// them to a stable empty map while hidden; committed edits still arrive via
|
||||||
|
// `useScene`, so the plan is current the moment the view is shown.
|
||||||
|
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
|
||||||
// Subscribe to the live-transforms map ref so the layer re-renders
|
// Subscribe to the live-transforms map ref so the layer re-renders
|
||||||
// whenever a 3D mover publishes a per-frame position (see
|
// whenever a 3D mover publishes a per-frame position (see
|
||||||
// `usePlacementCoordinator`). Without this the 2D floor plan only
|
// `usePlacementCoordinator`). Without this the 2D floor plan only
|
||||||
// updates after 3D commit — the 3D drag would look frozen in 2D.
|
// updates after 3D commit — the 3D drag would look frozen in 2D.
|
||||||
const liveTransforms = useLiveTransforms((s) => s.transforms)
|
const liveTransforms = useLiveTransforms((s) =>
|
||||||
|
floorplanVisible ? s.transforms : EMPTY_LIVE_TRANSFORMS,
|
||||||
|
)
|
||||||
// Same reactivity hook for elevator runtime state — `useInteractive`
|
// Same reactivity hook for elevator runtime state — `useInteractive`
|
||||||
// tracks the current / fallback level + cab travel, `useLiveNode
|
// tracks the current / fallback level + cab travel, `useLiveNode
|
||||||
// Overrides` carries live-edit overrides from the inspector. Builders
|
// Overrides` carries live-edit overrides from the inspector. Builders
|
||||||
// read both via `getState()` inside `def.floorplan`; subscribing here
|
// read both via `getState()` inside `def.floorplan`; subscribing here
|
||||||
// is what forces the layer to re-render when they change.
|
// is what forces the layer to re-render when they change.
|
||||||
const liveOverrides = useLiveNodeOverrides((s) => s.overrides)
|
const liveOverrides = useLiveNodeOverrides((s) =>
|
||||||
|
floorplanVisible ? s.overrides : EMPTY_LIVE_OVERRIDES,
|
||||||
|
)
|
||||||
const interactiveElevators = useInteractive((s) => s.elevators)
|
const interactiveElevators = useInteractive((s) => s.elevators)
|
||||||
|
|
||||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||||
@@ -239,6 +303,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
|
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||||
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
||||||
|
const geometryCacheRef = useRef<Map<string, CacheEntry>>(new Map())
|
||||||
|
const siblingEpochInputsRef = useRef<unknown[]>([])
|
||||||
|
const siblingEpochRef = useRef(0)
|
||||||
|
|
||||||
const applyEntrySelection = useCallback(
|
const applyEntrySelection = useCallback(
|
||||||
(id: AnyNodeId, shouldToggle: boolean) => {
|
(id: AnyNodeId, shouldToggle: boolean) => {
|
||||||
@@ -467,117 +534,223 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
// tree the builder returns. Builders don't need to know about the
|
// tree the builder returns. Builders don't need to know about the
|
||||||
// partition.
|
// partition.
|
||||||
const entries = useMemo(() => {
|
const entries = useMemo(() => {
|
||||||
// Some builders read elevator runtime state imperatively; this keeps the memo subscribed.
|
const previousCache = geometryCacheRef.current
|
||||||
void interactiveElevators
|
const nextCache = new Map<string, CacheEntry>()
|
||||||
|
if (!levelId) {
|
||||||
|
geometryCacheRef.current = nextCache
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
if (!levelId) return []
|
// The sibling epoch bumps whenever a sibling-affecting node's LIVE state
|
||||||
const out: {
|
// changes (a wall/door/window/gutter being dragged or live-edited). Only
|
||||||
id: AnyNodeId
|
// flagged kinds feed it, so dragging or rotating a plain item — which also
|
||||||
node: AnyNode
|
// publishes to liveTransforms / liveOverrides — leaves it stable and the
|
||||||
base: FloorplanGeometry | null
|
// hundreds of wall/door geometries stay cached. Committed structural edits
|
||||||
overlay: FloorplanGeometry | null
|
// are covered separately by keying flagged kinds on the `nodes` ref.
|
||||||
selected: boolean
|
const siblingEpochInputs: unknown[] = []
|
||||||
highlighted: boolean
|
for (const [id, live] of liveTransforms) {
|
||||||
}[] = []
|
const node = nodes[id as AnyNodeId]
|
||||||
|
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
|
||||||
|
siblingEpochInputs.push(live)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, override] of liveOverrides) {
|
||||||
|
const node = nodes[id as AnyNodeId]
|
||||||
|
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
|
||||||
|
siblingEpochInputs.push(override)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!depsValueEqual(siblingEpochInputsRef.current, siblingEpochInputs)) {
|
||||||
|
siblingEpochRef.current += 1
|
||||||
|
siblingEpochInputsRef.current = siblingEpochInputs
|
||||||
|
}
|
||||||
|
const siblingEpoch = siblingEpochRef.current
|
||||||
|
const out: FloorplanEntry[] = []
|
||||||
|
const levelDataByType = new Map<string, unknown>()
|
||||||
|
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
||||||
|
|
||||||
|
const collectLevelDataKind = (id: AnyNodeId) => {
|
||||||
|
const node = nodes[id]
|
||||||
|
if (!node) return
|
||||||
|
const def = nodeRegistry.get(node.type)
|
||||||
|
if (def?.computeFloorplanLevelData) {
|
||||||
|
const ids = levelNodeIdsByType.get(node.type)
|
||||||
|
if (ids) ids.push(id)
|
||||||
|
else levelNodeIdsByType.set(node.type, [id])
|
||||||
|
}
|
||||||
|
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
||||||
|
if (Array.isArray(childIds)) {
|
||||||
|
for (const cid of childIds) collectLevelDataKind(cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collectLevelDataKind(levelId as AnyNodeId)
|
||||||
|
|
||||||
|
for (const [type, ids] of levelNodeIdsByType) {
|
||||||
|
const def = nodeRegistry.get(type)
|
||||||
|
if (!def?.computeFloorplanLevelData) continue
|
||||||
|
const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
|
||||||
|
const sampleId = ids[0]
|
||||||
|
if (!sampleId) continue
|
||||||
|
const contextNodes = def.floorplanSiblingOverrides
|
||||||
|
? def.floorplanSiblingOverrides({ nodeId: sampleId, nodes, liveOverrides })
|
||||||
|
: nodes
|
||||||
|
const siblings: AnyNode[] = []
|
||||||
|
for (const id of ids) {
|
||||||
|
const sibling = contextNodes[id]
|
||||||
|
if (sibling?.type === type) siblings.push(sibling)
|
||||||
|
}
|
||||||
|
levelDataByType.set(type, computeLevelData({ siblings, nodes: contextNodes }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
|
||||||
|
const def = nodeRegistry.get(node.type)
|
||||||
|
const builder = def?.floorplan
|
||||||
|
if (!builder) return
|
||||||
|
const selected = selectedIdSet.has(id)
|
||||||
|
const highlighted = highlightedIdSet.has(id)
|
||||||
|
const hovered = hoveredId === id
|
||||||
|
const moving = movingNode?.id === id
|
||||||
|
const live = liveTransforms.get(id)
|
||||||
|
const dependsOnSiblingInputs = !!(
|
||||||
|
def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides
|
||||||
|
)
|
||||||
|
const deps: NodeDeps = {
|
||||||
|
node,
|
||||||
|
live,
|
||||||
|
selected,
|
||||||
|
highlighted,
|
||||||
|
hovered,
|
||||||
|
moving,
|
||||||
|
palette: renderCtx?.palette,
|
||||||
|
siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
|
||||||
|
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
||||||
|
// COMMITTED state via `ctx`, so a committed edit to a sibling/child that
|
||||||
|
// doesn't change this node's own ref must still invalidate it. The
|
||||||
|
// `nodes` ref is stable during a live drag (only commits replace it), so
|
||||||
|
// this preserves the live-drag cache win while matching the old
|
||||||
|
// rebuild-on-every-commit correctness. Self-contained kinds key on their
|
||||||
|
// own `node` ref only.
|
||||||
|
committedNodes: dependsOnSiblingInputs ? nodes : null,
|
||||||
|
// Elevator builders read runtime state imperatively, so every kind's
|
||||||
|
// cache key includes the rare-changing ref conservatively.
|
||||||
|
interactiveElevators,
|
||||||
|
}
|
||||||
|
const cached = previousCache.get(id)
|
||||||
|
if (cached && nodeDepsEqual(cached.deps, deps)) {
|
||||||
|
nextCache.set(id, cached)
|
||||||
|
if (cached.base || cached.overlay) {
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
node: cached.node,
|
||||||
|
base: cached.base,
|
||||||
|
overlay: cached.overlay,
|
||||||
|
selected,
|
||||||
|
highlighted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
|
||||||
|
if (!live) return sourceNode
|
||||||
|
const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
|
||||||
|
if (sourceNode.type === 'door' || sourceNode.type === 'window') {
|
||||||
|
// Door / window movers publish WALL-LOCAL live transforms
|
||||||
|
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
|
||||||
|
// wiki/architecture/tools.md. The mover only writes
|
||||||
|
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
|
||||||
|
// updates the 3D mesh imperatively but never the scene node —
|
||||||
|
// without applying the live transform here the 2D symbol stays
|
||||||
|
// frozen while the cursor slides. Merge the wall-local position +
|
||||||
|
// rotation onto the node but KEEP `parentId` (the wall) so
|
||||||
|
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
|
||||||
|
// real swing-arc / pane symbol at the live spot.
|
||||||
|
const r = (sourceNode as { rotation?: unknown }).rotation
|
||||||
|
return {
|
||||||
|
...sourceNode,
|
||||||
|
position: live.position,
|
||||||
|
rotation: Array.isArray(r)
|
||||||
|
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
|
||||||
|
: r,
|
||||||
|
} as AnyNode
|
||||||
|
}
|
||||||
|
if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) {
|
||||||
|
return applyPositionLiveTransform(sourceNode, live)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
sourceNode.type === 'slab' ||
|
||||||
|
sourceNode.type === 'ceiling' ||
|
||||||
|
sourceNode.type === 'zone'
|
||||||
|
) {
|
||||||
|
const dx = live.position[0]
|
||||||
|
const dz = live.position[2]
|
||||||
|
if (dx === 0 && dz === 0) return sourceNode
|
||||||
|
const surface = sourceNode as {
|
||||||
|
polygon: Array<[number, number]>
|
||||||
|
holes?: Array<Array<[number, number]>>
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...sourceNode,
|
||||||
|
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
||||||
|
holes: (surface.holes ?? []).map((h) =>
|
||||||
|
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
||||||
|
),
|
||||||
|
} as AnyNode
|
||||||
|
}
|
||||||
|
return sourceNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextNodes = def.floorplanSiblingOverrides
|
||||||
|
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
|
||||||
|
: nodes
|
||||||
|
const sourceNode = contextNodes !== nodes ? (contextNodes[id] ?? node) : node
|
||||||
|
const effectiveNode = applyLiveTransform(sourceNode)
|
||||||
|
const viewState = {
|
||||||
|
selected,
|
||||||
|
highlighted,
|
||||||
|
hovered,
|
||||||
|
moving,
|
||||||
|
palette: renderCtx?.palette,
|
||||||
|
}
|
||||||
|
const ctx: GeometryContext = ctxOverrides
|
||||||
|
? {
|
||||||
|
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
|
||||||
|
contextNodes[rid] as N | undefined,
|
||||||
|
children: ctxOverrides.children,
|
||||||
|
siblings: ctxOverrides.siblings,
|
||||||
|
parent: ctxOverrides.parent,
|
||||||
|
levelData: levelDataByType.get(node.type),
|
||||||
|
viewState: renderCtx?.palette
|
||||||
|
? {
|
||||||
|
selected,
|
||||||
|
highlighted,
|
||||||
|
hovered,
|
||||||
|
moving,
|
||||||
|
palette: renderCtx.palette,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: buildContext(effectiveNode, contextNodes, viewState, levelDataByType.get(node.type))
|
||||||
|
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
||||||
|
effectiveNode,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
const { base, overlay } = geometry
|
||||||
|
? splitFloorplanOverlay(geometry)
|
||||||
|
: { base: null, overlay: null }
|
||||||
|
const entry: CacheEntry = { deps, base, overlay, node: effectiveNode }
|
||||||
|
nextCache.set(id, entry)
|
||||||
|
if (base || overlay) {
|
||||||
|
out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const visit = (id: AnyNodeId) => {
|
const visit = (id: AnyNodeId) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
if (!node) return
|
if (!node) return
|
||||||
if ((node as { visible?: boolean }).visible === false) return
|
if ((node as { visible?: boolean }).visible === false) return
|
||||||
const def = nodeRegistry.get(node.type)
|
buildEntry(id, node)
|
||||||
const builder = def?.floorplan
|
|
||||||
if (builder) {
|
|
||||||
const selected = selectedIdSet.has(id)
|
|
||||||
const highlighted = highlightedIdSet.has(id)
|
|
||||||
const hovered = hoveredId === id
|
|
||||||
const moving = movingNode?.id === id
|
|
||||||
// Live-transform override — when a mover is publishing per-frame
|
|
||||||
// position/rotation, render that here instead of the committed
|
|
||||||
// scene state. Without this the 2D floor plan would only update
|
|
||||||
// after commit, making the drag look frozen.
|
|
||||||
//
|
|
||||||
// The live-transform contract varies per kind (see
|
|
||||||
// wiki/architecture/tools.md "useLiveTransforms contract is
|
|
||||||
// per-kind, not generic"); position-carrying floor-placed kinds
|
|
||||||
// publish canonical X/Z, while slab / ceiling publish a polygon
|
|
||||||
// translation delta.
|
|
||||||
const live = liveTransforms.get(id)
|
|
||||||
let effectiveNode: AnyNode = node
|
|
||||||
if (live) {
|
|
||||||
const floorPlaced = def?.capabilities?.floorPlaced
|
|
||||||
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
|
||||||
if (node.type === 'door' || node.type === 'window') {
|
|
||||||
// Door / window movers publish WALL-LOCAL live transforms
|
|
||||||
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
|
|
||||||
// wiki/architecture/tools.md. The mover only writes
|
|
||||||
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
|
|
||||||
// updates the 3D mesh imperatively but never the scene node —
|
|
||||||
// without applying the live transform here the 2D symbol stays
|
|
||||||
// frozen while the cursor slides. Merge the wall-local position +
|
|
||||||
// rotation onto the node but KEEP `parentId` (the wall) so
|
|
||||||
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
|
|
||||||
// real swing-arc / pane symbol at the live spot.
|
|
||||||
const r = (node as { rotation?: unknown }).rotation
|
|
||||||
effectiveNode = {
|
|
||||||
...node,
|
|
||||||
position: live.position,
|
|
||||||
rotation: Array.isArray(r)
|
|
||||||
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
|
|
||||||
: r,
|
|
||||||
} as AnyNode
|
|
||||||
} else if (floorPlaced && hasPosition) {
|
|
||||||
effectiveNode = applyPositionLiveTransform(node, live)
|
|
||||||
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
|
||||||
const dx = live.position[0]
|
|
||||||
const dz = live.position[2]
|
|
||||||
if (dx !== 0 || dz !== 0) {
|
|
||||||
const surface = node as {
|
|
||||||
polygon: Array<[number, number]>
|
|
||||||
holes?: Array<Array<[number, number]>>
|
|
||||||
}
|
|
||||||
effectiveNode = {
|
|
||||||
...node,
|
|
||||||
polygon: surface.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
||||||
holes: (surface.holes ?? []).map((h) =>
|
|
||||||
h.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
|
||||||
),
|
|
||||||
} as AnyNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Live-edit overrides: kinds whose `def.floorplan` builder
|
|
||||||
// reads cross-sibling data (wall miters, …) declare a
|
|
||||||
// `def.floorplanSiblingOverrides` hook that projects the
|
|
||||||
// override map into a merged `nodes` snapshot. The merged
|
|
||||||
// copy feeds `buildContext` so `ctx.siblings` reflects the
|
|
||||||
// live cursor positions, and replaces `effectiveNode` so the
|
|
||||||
// kind's own override lands too (covers the case where the
|
|
||||||
// node being rendered is itself the dragged one). Kinds
|
|
||||||
// without the hook hand the raw `nodes` through — most
|
|
||||||
// previews are self-contained.
|
|
||||||
const contextNodes = def?.floorplanSiblingOverrides
|
|
||||||
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
|
|
||||||
: nodes
|
|
||||||
if (contextNodes !== nodes) {
|
|
||||||
const merged = contextNodes[id]
|
|
||||||
if (merged) effectiveNode = merged
|
|
||||||
}
|
|
||||||
const ctx = buildContext(effectiveNode, contextNodes, {
|
|
||||||
selected,
|
|
||||||
highlighted,
|
|
||||||
hovered,
|
|
||||||
moving,
|
|
||||||
palette: renderCtx?.palette,
|
|
||||||
})
|
|
||||||
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
||||||
effectiveNode,
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
if (geometry) {
|
|
||||||
const { base, overlay } = splitFloorplanOverlay(geometry)
|
|
||||||
out.push({ id, node: effectiveNode, base, overlay, selected, highlighted })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
||||||
if (Array.isArray(childIds)) {
|
if (Array.isArray(childIds)) {
|
||||||
for (const cid of childIds) visit(cid)
|
for (const cid of childIds) visit(cid)
|
||||||
@@ -607,50 +780,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
|
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
|
||||||
if (parentId !== activeBuildingId) continue
|
if (parentId !== activeBuildingId) continue
|
||||||
const cid = id as AnyNodeId
|
const cid = id as AnyNodeId
|
||||||
const def = nodeRegistry.get(node.type)
|
buildEntry(cid, node, {
|
||||||
const builder = def?.floorplan
|
|
||||||
if (!builder) continue
|
|
||||||
const selected = selectedIdSet.has(cid)
|
|
||||||
const highlighted = highlightedIdSet.has(cid)
|
|
||||||
const hovered = hoveredId === cid
|
|
||||||
const moving = movingNode?.id === cid
|
|
||||||
const live = liveTransforms.get(cid)
|
|
||||||
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
|
||||||
let effectiveNode: AnyNode =
|
|
||||||
live && hasPosition ? applyPositionLiveTransform(node, live) : node
|
|
||||||
const contextNodes = def?.floorplanSiblingOverrides
|
|
||||||
? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides })
|
|
||||||
: nodes
|
|
||||||
if (contextNodes !== nodes) {
|
|
||||||
const merged = contextNodes[cid]
|
|
||||||
if (merged) {
|
|
||||||
effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ctx: GeometryContext = {
|
|
||||||
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
|
|
||||||
contextNodes[rid] as N | undefined,
|
|
||||||
children: [],
|
children: [],
|
||||||
siblings: [],
|
siblings: [],
|
||||||
parent: activeLevelNode,
|
parent: activeLevelNode,
|
||||||
viewState: renderCtx?.palette
|
})
|
||||||
? {
|
|
||||||
selected,
|
|
||||||
highlighted,
|
|
||||||
hovered,
|
|
||||||
moving,
|
|
||||||
palette: renderCtx.palette,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
|
||||||
effectiveNode,
|
|
||||||
ctx,
|
|
||||||
)
|
|
||||||
if (geometry) {
|
|
||||||
const { base, overlay } = splitFloorplanOverlay(geometry)
|
|
||||||
out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,6 +796,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
// DFS visit order (stable sort) so siblings keep their relative
|
// DFS visit order (stable sort) so siblings keep their relative
|
||||||
// priority.
|
// priority.
|
||||||
out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
||||||
|
geometryCacheRef.current = nextCache
|
||||||
return out
|
return out
|
||||||
}, [
|
}, [
|
||||||
levelId,
|
levelId,
|
||||||
@@ -991,6 +1126,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
suppressBoxSelectForPointer(event)
|
suppressBoxSelectForPointer(event)
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
setMovingNode(node as never)
|
setMovingNode(node as never)
|
||||||
|
// Claim 2D ownership of this move at the source. `setMovingNode`
|
||||||
|
// resets the origin to null, so this must follow it. It gates the
|
||||||
|
// 3D affordance mover (`ToolManager`) off entirely: without it the
|
||||||
|
// 3D `MoveItemTool` would also mount, `adopt()` the same node, and
|
||||||
|
// restore its adopt-time (original) position from its unmount
|
||||||
|
// `destroy()` — snapping a committed 2D move back to its start.
|
||||||
|
setMovingNodeOrigin('2d')
|
||||||
}}
|
}}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
||||||
@@ -1947,6 +2089,7 @@ function buildContext(
|
|||||||
moving: boolean
|
moving: boolean
|
||||||
palette: FloorplanPalette | undefined
|
palette: FloorplanPalette | undefined
|
||||||
},
|
},
|
||||||
|
levelData?: unknown,
|
||||||
): GeometryContext {
|
): GeometryContext {
|
||||||
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
||||||
|
|
||||||
@@ -1979,6 +2122,7 @@ function buildContext(
|
|||||||
children,
|
children,
|
||||||
siblings,
|
siblings,
|
||||||
parent,
|
parent,
|
||||||
|
levelData,
|
||||||
viewState: viewState.palette
|
viewState: viewState.palette
|
||||||
? {
|
? {
|
||||||
selected: viewState.selected,
|
selected: viewState.selected,
|
||||||
@@ -2071,6 +2215,37 @@ function splitFloorplanOverlay(g: FloorplanGeometry): {
|
|||||||
return { base: g, overlay: null }
|
return { base: g, overlay: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
||||||
|
const keys: Array<keyof NodeDeps> = [
|
||||||
|
'node',
|
||||||
|
'live',
|
||||||
|
'selected',
|
||||||
|
'highlighted',
|
||||||
|
'hovered',
|
||||||
|
'moving',
|
||||||
|
'palette',
|
||||||
|
'siblingEpoch',
|
||||||
|
'committedNodes',
|
||||||
|
'interactiveElevators',
|
||||||
|
]
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!depsValueEqual(a[key], b[key])) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function depsValueEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (Array.isArray(a) || Array.isArray(b)) {
|
||||||
|
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (!Object.is(a[i], b[i])) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return Object.is(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Z-order bucket for floor-plan rendering. Lower rank = painted first =
|
* Z-order bucket for floor-plan rendering. Lower rank = painted first =
|
||||||
* sits under everything with a higher rank. SVG renders in document
|
* sits under everything with a higher rank. SVG renders in document
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ import { Html } from '@react-three/drei'
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useCallback, useMemo, useRef } from 'react'
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
||||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
import useInteractionScope from '../../store/use-interaction-scope'
|
||||||
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
||||||
import { NodeActionMenu } from './node-action-menu'
|
import { NodeActionMenu } from './node-action-menu'
|
||||||
|
|
||||||
@@ -137,6 +139,11 @@ function getAttributeVersion(
|
|||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
|
||||||
|
// dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
|
||||||
|
const _anchorBox = new THREE.Box3()
|
||||||
|
const _anchorCenter = new THREE.Vector3()
|
||||||
|
|
||||||
function getObjectGeometryKey(object: THREE.Object3D): string {
|
function getObjectGeometryKey(object: THREE.Object3D): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
object.traverse((child) => {
|
object.traverse((child) => {
|
||||||
@@ -218,6 +225,10 @@ export function FloatingActionMenu() {
|
|||||||
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
||||||
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
||||||
const rotationAxis = useEditor((s) => s.rotationAxis)
|
const rotationAxis = useEditor((s) => s.rotationAxis)
|
||||||
|
// The floating action menu is an action-conflicting control: hard-hidden
|
||||||
|
// during any active interaction so it never competes with the live action.
|
||||||
|
const scope = useInteractionScope((s) => s.scope)
|
||||||
|
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
|
||||||
|
|
||||||
const groupRef = useRef<THREE.Group>(null)
|
const groupRef = useRef<THREE.Group>(null)
|
||||||
const menuScaleRef = useRef<HTMLDivElement>(null)
|
const menuScaleRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -329,23 +340,44 @@ export function FloatingActionMenu() {
|
|||||||
// mid-resize). A spinning child changes the head's matrix, not the
|
// mid-resize). A spinning child changes the head's matrix, not the
|
||||||
// registered group's, so it never triggers a recompute → the menu
|
// registered group's, so it never triggers a recompute → the menu
|
||||||
// holds still.
|
// holds still.
|
||||||
|
// Cheapest guards first: a selection swap, the object's own world
|
||||||
|
// transform changing (true every frame during a drag), a live override,
|
||||||
|
// or an active handle drag all force a recompute on their own — so skip
|
||||||
|
// the geometry traversal (`getObjectGeometryKey` walks the whole subtree
|
||||||
|
// reading attribute versions) until none of them fired and a
|
||||||
|
// geometry-only change is the only thing left that could move the anchor.
|
||||||
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
|
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
|
||||||
const dragActive = activeHandleDrag?.nodeId === selectedId
|
const dragActive = activeHandleDrag?.nodeId === selectedId
|
||||||
const effectiveNode = getEffectiveNode(node)
|
|
||||||
const geometryKey = getObjectGeometryKey(obj)
|
|
||||||
const selectionChanged =
|
const selectionChanged =
|
||||||
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
|
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
|
||||||
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
|
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
|
||||||
const geometryChanged = lastAnchorKeyRef.current.geometryKey !== geometryKey
|
|
||||||
|
|
||||||
if (selectionChanged || matrixChanged || geometryChanged || overrideActive || dragActive) {
|
let geometryKey = lastAnchorKeyRef.current.geometryKey
|
||||||
|
let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive
|
||||||
|
// Only when nothing cheaper fired do we pay for the subtree traversal —
|
||||||
|
// a geometry-only change is the lone remaining trigger. When a cheaper
|
||||||
|
// guard already forced a recompute the stored key is reused; the matrix
|
||||||
|
// (or override/drag) keeps recomputing the anchor every frame, so a
|
||||||
|
// geometry edit mid-drag is absorbed, and the next idle frame refreshes
|
||||||
|
// the key against the live geometry.
|
||||||
|
if (!needsRecompute) {
|
||||||
|
geometryKey = getObjectGeometryKey(obj)
|
||||||
|
if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsRecompute) {
|
||||||
|
const effectiveNode = getEffectiveNode(node)
|
||||||
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
|
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
|
||||||
const box = new THREE.Box3().setFromObject(obj)
|
_anchorBox.setFromObject(obj)
|
||||||
if (!box.isEmpty()) {
|
if (!_anchorBox.isEmpty()) {
|
||||||
const center = box.getCenter(new THREE.Vector3())
|
_anchorBox.getCenter(_anchorCenter)
|
||||||
// Position above the object. Per-type offsets clear each kind's
|
// Position above the object. Per-type offsets clear each kind's
|
||||||
// in-world chrome (height-resize arrows, measurement labels).
|
// in-world chrome (height-resize arrows, measurement labels).
|
||||||
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(effectiveNode), center.z)
|
anchorRef.current.set(
|
||||||
|
_anchorCenter.x,
|
||||||
|
_anchorBox.max.y + getMenuYOffset(effectiveNode),
|
||||||
|
_anchorCenter.z,
|
||||||
|
)
|
||||||
hasAnchorRef.current = true
|
hasAnchorRef.current = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -624,7 +656,8 @@ export function FloatingActionMenu() {
|
|||||||
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
||||||
movingWallEndpoint ||
|
movingWallEndpoint ||
|
||||||
movingFenceEndpoint ||
|
movingFenceEndpoint ||
|
||||||
curvingFence
|
curvingFence ||
|
||||||
|
menuStepBack
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ import {
|
|||||||
buildFloorplanItemEntry,
|
buildFloorplanItemEntry,
|
||||||
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
||||||
collectLevelDescendants,
|
collectLevelDescendants,
|
||||||
|
floorplanLocalToWorldPoint,
|
||||||
getFloorplanWall as getSharedFloorplanWall,
|
getFloorplanWall as getSharedFloorplanWall,
|
||||||
rotatePlanVector as rotateSharedPlanVector,
|
rotatePlanVector as rotateSharedPlanVector,
|
||||||
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
||||||
|
worldToFloorplanLocalPoint,
|
||||||
} from '../../lib/floorplan'
|
} from '../../lib/floorplan'
|
||||||
import { guideEmitter } from '../../lib/guide-events'
|
import { guideEmitter } from '../../lib/guide-events'
|
||||||
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
|
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
|
||||||
@@ -87,7 +89,11 @@ import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
|||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
||||||
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
||||||
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
|
import useEditor, {
|
||||||
|
isAngleSnapActive,
|
||||||
|
isMagneticSnapActive,
|
||||||
|
selectSiteFloorplanContext,
|
||||||
|
} from '../../store/use-editor'
|
||||||
import usePlacementPreview from '../../store/use-placement-preview'
|
import usePlacementPreview from '../../store/use-placement-preview'
|
||||||
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
||||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||||
@@ -1793,39 +1799,6 @@ function cameraAzimuthFromFloorplanRotation(rotationDeg: number) {
|
|||||||
return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG)
|
return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG)
|
||||||
}
|
}
|
||||||
|
|
||||||
function floorplanLocalToWorldPoint(
|
|
||||||
point: SvgPoint | WallPlanPoint,
|
|
||||||
buildingPosition: readonly [number, number, number],
|
|
||||||
buildingRotationY: number,
|
|
||||||
): { x: number; z: number } {
|
|
||||||
const localX = Array.isArray(point) ? point[0] : point.x
|
|
||||||
const localY = Array.isArray(point) ? point[1] : point.y
|
|
||||||
const cos = Math.cos(buildingRotationY)
|
|
||||||
const sin = Math.sin(buildingRotationY)
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: buildingPosition[0] + localX * cos + localY * sin,
|
|
||||||
z: buildingPosition[2] - localX * sin + localY * cos,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function worldToFloorplanLocalPoint(
|
|
||||||
worldX: number,
|
|
||||||
worldZ: number,
|
|
||||||
buildingPosition: readonly [number, number, number],
|
|
||||||
buildingRotationY: number,
|
|
||||||
): SvgPoint {
|
|
||||||
const dx = worldX - buildingPosition[0]
|
|
||||||
const dz = worldZ - buildingPosition[2]
|
|
||||||
const cos = Math.cos(buildingRotationY)
|
|
||||||
const sin = Math.sin(buildingRotationY)
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: dx * cos - dz * sin,
|
|
||||||
y: dx * sin + dz * cos,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectSvgPointToSurface(
|
function projectSvgPointToSurface(
|
||||||
svgPoint: SvgPoint,
|
svgPoint: SvgPoint,
|
||||||
viewBox: { minX: number; minY: number; width: number; height: number },
|
viewBox: { minX: number; minY: number; width: number; height: number },
|
||||||
@@ -7678,7 +7651,7 @@ export function FloorplanPanel({
|
|||||||
walls,
|
walls,
|
||||||
ignoreWallIds: [dragState.wallId],
|
ignoreWallIds: [dragState.wallId],
|
||||||
bypassSnap,
|
bypassSnap,
|
||||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
const snappedPoint = snapResult.point
|
const snappedPoint = snapResult.point
|
||||||
// Magnetic beacon at the endpoint when it locked onto existing geometry.
|
// Magnetic beacon at the endpoint when it locked onto existing geometry.
|
||||||
@@ -8537,30 +8510,30 @@ export function FloorplanPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isFenceBuildActive) {
|
if (isFenceBuildActive) {
|
||||||
const bypassSnap = shiftPressed || event.shiftKey
|
|
||||||
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
|
// Fence draft: grid snap (+ existing-wall/fence endpoint snap), then
|
||||||
// Figma alignment — same endpoint-wins precedence as the wall branch.
|
// Figma alignment — same endpoint-wins precedence as the wall branch.
|
||||||
// While a draft is open the segment locks to 15° rays from its start
|
// While a draft is open the segment locks to 15° rays from its start.
|
||||||
// unless Shift is held; Shift bypasses grid, magnetic, angle, and
|
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||||
// alignment snap.
|
// there is no Shift hold-to-bypass. Alt still bypasses Figma alignment.
|
||||||
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
|
const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
|
||||||
const fenceSnapped = snapFenceDraftPoint({
|
const fenceSnapped = snapFenceDraftPoint({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
walls,
|
walls,
|
||||||
fences,
|
fences,
|
||||||
start: fenceDraftStart ?? undefined,
|
start: fenceDraftStart ?? undefined,
|
||||||
angleSnap: fenceAngleSnap,
|
angleSnap: fenceAngleSnap,
|
||||||
bypassSnap,
|
magnetic: isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
const fenceGridBase = bypassSnap ? planPoint : snapWallPointToGrid(planPoint)
|
const fenceGridBase = snapWallPointToGrid(planPoint)
|
||||||
const fenceLocked =
|
const fenceLocked =
|
||||||
!bypassSnap &&
|
fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]
|
||||||
(fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1])
|
|
||||||
let snappedPoint = fenceSnapped
|
let snappedPoint = fenceSnapped
|
||||||
if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear()
|
if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear()
|
||||||
else
|
else
|
||||||
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
|
snappedPoint = alignFloorplanDraftPoint(fenceSnapped, {
|
||||||
bypass: event.altKey || bypassSnap,
|
// Alignment is a line snap (pulls onto existing corners/edges) —
|
||||||
|
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
||||||
|
bypass: event.altKey || !isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
|
|
||||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||||
@@ -8738,18 +8711,16 @@ export function FloorplanPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wall draft: grid + magnetic snap, then Figma-style alignment.
|
// Wall draft: grid + magnetic snap, then Figma-style alignment.
|
||||||
// While a draft is open the segment locks to 15° rays from its
|
// While a draft is open the segment locks to 15° rays from its start.
|
||||||
// start unless Shift is held. Shift bypasses grid, magnetic, angle,
|
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||||
// and alignment snap.
|
// there is no Shift hold-to-bypass. Alt still bypasses Figma alignment.
|
||||||
const bypassSnap = shiftPressed || event.shiftKey
|
const wallAngleSnap = draftStart !== null && isAngleSnapActive()
|
||||||
const wallAngleSnap = draftStart !== null && !bypassSnap
|
|
||||||
const wallSnap = snapWallDraftPointDetailed({
|
const wallSnap = snapWallDraftPointDetailed({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
walls,
|
walls,
|
||||||
start: draftStart ?? undefined,
|
start: draftStart ?? undefined,
|
||||||
angleSnap: wallAngleSnap,
|
angleSnap: wallAngleSnap,
|
||||||
bypassSnap,
|
magnetic: isMagneticSnapActive(),
|
||||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
|
||||||
})
|
})
|
||||||
const wallSnapped = wallSnap.point
|
const wallSnapped = wallSnap.point
|
||||||
// Locked onto existing geometry (corner / midpoint / crossing / edge) →
|
// Locked onto existing geometry (corner / midpoint / crossing / edge) →
|
||||||
@@ -8761,7 +8732,9 @@ export function FloorplanPanel({
|
|||||||
} else {
|
} else {
|
||||||
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
||||||
applySnap: !wallAngleSnap,
|
applySnap: !wallAngleSnap,
|
||||||
bypass: event.altKey || bypassSnap,
|
// Alignment is a line snap (pulls onto existing corners/edges) —
|
||||||
|
// suppress it whenever magnetic snap is off (`'off'` / `'angles'`).
|
||||||
|
bypass: event.altKey || !isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
useWallSnapIndicator
|
useWallSnapIndicator
|
||||||
@@ -8780,8 +8753,9 @@ export function FloorplanPanel({
|
|||||||
|
|
||||||
setDraftEnd((previousEnd) => {
|
setDraftEnd((previousEnd) => {
|
||||||
if (
|
if (
|
||||||
!bypassSnap &&
|
!previousEnd ||
|
||||||
(!previousEnd || previousEnd[0] !== snappedPoint[0] || previousEnd[1] !== snappedPoint[1])
|
previousEnd[0] !== snappedPoint[0] ||
|
||||||
|
previousEnd[1] !== snappedPoint[1]
|
||||||
) {
|
) {
|
||||||
sfxEmitter.emit('sfx:grid-snap')
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
@@ -9044,7 +9018,7 @@ export function FloorplanPanel({
|
|||||||
angleSnap?: boolean
|
angleSnap?: boolean
|
||||||
bypassSnap?: boolean
|
bypassSnap?: boolean
|
||||||
step?: number
|
step?: number
|
||||||
}) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }),
|
}) => snapWallDraftPoint({ ...args, magnetic: isMagneticSnapActive() }),
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
|
const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
|
||||||
|
|||||||
@@ -269,15 +269,27 @@ export function createArrowHitAreaGeometry() {
|
|||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The move cross is a plus, not a disk. A disk-shaped hit area fills the four
|
||||||
|
// corner gaps between the arms, so a neighbouring node sitting next to the
|
||||||
|
// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the
|
||||||
|
// invisible grip and can't be picked. Wrap the visible arms instead: two flat
|
||||||
|
// arm boxes (length/width + margin) merged into a plus, leaving the corners
|
||||||
|
// empty so co-located neighbours stay selectable while the grip stays grabbable.
|
||||||
function createMoveCrossHitAreaGeometry() {
|
function createMoveCrossHitAreaGeometry() {
|
||||||
const geometry = new CylinderGeometry(
|
const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2
|
||||||
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
|
const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2
|
||||||
MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN,
|
const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth)
|
||||||
HIT_AREA_THICKNESS,
|
const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength)
|
||||||
32,
|
const merged = mergeGeometries([armX, armZ], false)
|
||||||
)
|
if (!merged) {
|
||||||
geometry.computeBoundingSphere()
|
armZ.dispose()
|
||||||
return geometry
|
armX.computeBoundingSphere()
|
||||||
|
return armX
|
||||||
|
}
|
||||||
|
armX.dispose()
|
||||||
|
armZ.dispose()
|
||||||
|
merged.computeBoundingSphere()
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRotateArrowHitAreaGeometry() {
|
export function createRotateArrowHitAreaGeometry() {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
|||||||
|
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
|
import { ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||||
import { createEditorApi } from '../../lib/editor-api'
|
import { createEditorApi } from '../../lib/editor-api'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||||
@@ -177,7 +178,6 @@ export function NodeArrowHandles() {
|
|||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
const placementDragMode = useEditor((state) => state.placementDragMode)
|
|
||||||
// Endpoint / curve drags reshape the selected wall or fence; hide its
|
// Endpoint / curve drags reshape the selected wall or fence; hide its
|
||||||
// resize arrows for the duration so they don't clutter (or get blocked
|
// resize arrows for the duration so they don't clutter (or get blocked
|
||||||
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
|
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
|
||||||
@@ -203,9 +203,6 @@ export function NodeArrowHandles() {
|
|||||||
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
|
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
|
||||||
[rawNode, liveOverride],
|
[rawNode, liveOverride],
|
||||||
)
|
)
|
||||||
const isOwnPressDragMove =
|
|
||||||
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
|
|
||||||
|
|
||||||
const def = node ? nodeRegistry.get(node.type) : null
|
const def = node ? nodeRegistry.get(node.type) : null
|
||||||
const descriptors = useMemo(() => {
|
const descriptors = useMemo(() => {
|
||||||
if (!(node && def?.handles)) return null
|
if (!(node && def?.handles)) return null
|
||||||
@@ -218,7 +215,11 @@ export function NodeArrowHandles() {
|
|||||||
Boolean(node && descriptors?.length) &&
|
Boolean(node && descriptors?.length) &&
|
||||||
!isFloorplanHovered &&
|
!isFloorplanHovered &&
|
||||||
mode !== 'delete' &&
|
mode !== 'delete' &&
|
||||||
(!movingNode || isOwnPressDragMove) &&
|
// Any whole-node move (placement or press-drag) hides the rig: the item is
|
||||||
|
// following the cursor, so its rotate/resize handles would only clutter and
|
||||||
|
// draw stray selection rays. The active handle-drag scope (resize/rotate)
|
||||||
|
// sets `activeHandleDrag`, not `movingNode`, so those are unaffected.
|
||||||
|
!movingNode &&
|
||||||
!movingWallEndpoint &&
|
!movingWallEndpoint &&
|
||||||
!movingFenceEndpoint &&
|
!movingFenceEndpoint &&
|
||||||
!curvingWall &&
|
!curvingWall &&
|
||||||
@@ -398,22 +399,30 @@ function NodeArrowHandlesForNode({
|
|||||||
// resize that re-centres the mesh) must NOT fire for the non-active arrows
|
// resize that re-centres the mesh) must NOT fire for the non-active arrows
|
||||||
// here, or they'd lag behind the moving item.
|
// here, or they'd lag behind the moving item.
|
||||||
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
|
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
|
||||||
|
// While a rotate gizmo is mid-drag, drop the opposite-side move cross: you
|
||||||
|
// can't move and rotate at once, so it only clutters the rotation.
|
||||||
|
const activeDescriptor = activeIndex !== null ? descriptors[activeIndex] : undefined
|
||||||
|
const activeIsRotate =
|
||||||
|
!!activeDescriptor && 'shape' in activeDescriptor && activeDescriptor.shape === 'rotate'
|
||||||
|
|
||||||
const arrows = descriptors.map((descriptor, index) => (
|
const arrows = descriptors.map((descriptor, index) => {
|
||||||
<ArrowHandle
|
if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
|
||||||
activeIndex={activeIndex}
|
return (
|
||||||
descriptor={descriptor}
|
<ArrowHandle
|
||||||
dragControls={dragControls}
|
activeIndex={activeIndex}
|
||||||
handleIndex={index}
|
descriptor={descriptor}
|
||||||
// Descriptors come from a per-node-kind static list, so index is a
|
dragControls={dragControls}
|
||||||
// stable identity within this node's selection cycle.
|
handleIndex={index}
|
||||||
key={index}
|
// Descriptors come from a per-node-kind static list, so index is a
|
||||||
liveNode={node}
|
// stable identity within this node's selection cycle.
|
||||||
preDragNode={preDragNode}
|
key={index}
|
||||||
rideObject={arrowFrame}
|
liveNode={node}
|
||||||
suppressFreeze={activeIsTranslate}
|
preDragNode={preDragNode}
|
||||||
/>
|
rideObject={arrowFrame}
|
||||||
))
|
suppressFreeze={activeIsTranslate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<group ref={outerRef}>
|
<group ref={outerRef}>
|
||||||
@@ -1135,8 +1144,21 @@ function ArcArrow({
|
|||||||
}
|
}
|
||||||
const initialAngle = angleOf(hitWorld)
|
const initialAngle = angleOf(hitWorld)
|
||||||
|
|
||||||
|
// Advertise the rotate interaction so the contextual HUD can surface the
|
||||||
|
// Shift = free-rotation toggle (the angle-step bypass below). Resize
|
||||||
|
// handles route a measurement label here; rotate gets a sentinel label so
|
||||||
|
// the HUD shows the rotate hint, not a dimension pill.
|
||||||
|
if (isRotateShape) {
|
||||||
|
useEditor
|
||||||
|
.getState()
|
||||||
|
.setActiveHandleDrag({ nodeId: node.id, label: ROTATE_HANDLE_DRAG_LABEL })
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onEnd: () => setRotationDelta(null),
|
onEnd: () => {
|
||||||
|
setRotationDelta(null)
|
||||||
|
if (isRotateShape) useEditor.getState().setActiveHandleDrag(null)
|
||||||
|
},
|
||||||
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
||||||
const hit = new Vector3()
|
const hit = new Vector3()
|
||||||
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
|
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
|||||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
|
import { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
|
||||||
import usePlacementPreview from '../../store/use-placement-preview'
|
import usePlacementPreview from '../../store/use-placement-preview'
|
||||||
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
||||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||||
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||||
|
|
||||||
type UseFloorplanBackgroundPlacementArgs = {
|
type UseFloorplanBackgroundPlacementArgs = {
|
||||||
activePolygonDraftPoints: WallPlanPoint[]
|
activePolygonDraftPoints: WallPlanPoint[]
|
||||||
@@ -212,8 +213,8 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
// start unless Shift is held; Shift bypasses grid, magnetic,
|
// start unless Shift is held; Shift bypasses grid, magnetic,
|
||||||
// angle, and alignment snap. `gridSnap` keeps the regular snap
|
// angle, and alignment snap. `gridSnap` keeps the regular snap
|
||||||
// on the world XZ grid even when the building is rotated.
|
// on the world XZ grid even when the building is rotated.
|
||||||
const fenceStep = WALL_GRID_STEP
|
const fenceStep = getSegmentGridStep()
|
||||||
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap
|
const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap && isAngleSnapActive()
|
||||||
const fenceSnapped = snapFenceDraftPoint({
|
const fenceSnapped = snapFenceDraftPoint({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
walls,
|
walls,
|
||||||
@@ -221,6 +222,7 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
start: fenceDraftStart ?? undefined,
|
start: fenceDraftStart ?? undefined,
|
||||||
angleSnap: fenceAngleSnap,
|
angleSnap: fenceAngleSnap,
|
||||||
bypassSnap,
|
bypassSnap,
|
||||||
|
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||||
gridSnap: (p) => worldGridSnap(p, fenceStep),
|
gridSnap: (p) => worldGridSnap(p, fenceStep),
|
||||||
})
|
})
|
||||||
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep)
|
const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep)
|
||||||
@@ -230,7 +232,9 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
const snappedPoint =
|
const snappedPoint =
|
||||||
fenceLocked || fenceAngleSnap
|
fenceLocked || fenceAngleSnap
|
||||||
? fenceSnapped
|
? fenceSnapped
|
||||||
: alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap })
|
: alignFloorplanDraftPoint(fenceSnapped, {
|
||||||
|
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(),
|
||||||
|
})
|
||||||
|
|
||||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||||
setCursorPoint(snappedPoint)
|
setCursorPoint(snappedPoint)
|
||||||
@@ -321,8 +325,8 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
// start unless Shift is held; Shift bypasses grid, magnetic,
|
// start unless Shift is held; Shift bypasses grid, magnetic,
|
||||||
// angle, and alignment snap. `gridSnap` keeps the regular snap
|
// angle, and alignment snap. `gridSnap` keeps the regular snap
|
||||||
// on the world XZ grid even when the building is rotated.
|
// on the world XZ grid even when the building is rotated.
|
||||||
const wallStep = WALL_GRID_STEP
|
const wallStep = getSegmentGridStep()
|
||||||
const wallAngleSnap = draftStart !== null && !bypassSnap
|
const wallAngleSnap = draftStart !== null && !bypassSnap && isAngleSnapActive()
|
||||||
const wallSnapped = snapWallDraftPoint({
|
const wallSnapped = snapWallDraftPoint({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
walls,
|
walls,
|
||||||
@@ -340,7 +344,10 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
} else {
|
} else {
|
||||||
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
snappedPoint = alignFloorplanDraftPoint(wallSnapped, {
|
||||||
applySnap: !wallAngleSnap,
|
applySnap: !wallAngleSnap,
|
||||||
bypass: event.altKey || bypassSnap,
|
// Figma alignment pulls the endpoint onto existing wall corners /
|
||||||
|
// edges, so it is a line snap — suppress it whenever magnetic snap
|
||||||
|
// is off (`'off'` / `'angles'`), matching the wall-geometry snap.
|
||||||
|
bypass: event.altKey || bypassSnap || !isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { Check, Pencil } from 'lucide-react'
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import useInteractionScope from '../../../store/use-interaction-scope'
|
||||||
|
|
||||||
// ─── Per-zone label editor ────────────────────────────────────────────────────
|
// ─── Per-zone label editor ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
|||||||
const selectedZoneId = useViewer((s) => s.selection.zoneId)
|
const selectedZoneId = useViewer((s) => s.selection.zoneId)
|
||||||
const hoveredId = useViewer((s) => s.hoveredId)
|
const hoveredId = useViewer((s) => s.hoveredId)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
|
// During an active interaction the zone label is a context badge that steps
|
||||||
|
// back: faded + non-interactive so it can't be hovered/clicked mid-action.
|
||||||
|
const scope = useInteractionScope((s) => s.scope)
|
||||||
|
const labelStepBack = resolveOverlayPolicy(scope).contextBadges === 'faded'
|
||||||
const isSelected = selectedZoneId === zoneId
|
const isSelected = selectedZoneId === zoneId
|
||||||
const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
|
const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
@@ -149,7 +155,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'sans-serif',
|
fontFamily: 'sans-serif',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: labelStepBack ? 'none' : 'auto',
|
||||||
|
opacity: labelStepBack ? 0.4 : undefined,
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { type Group, MathUtils, type Mesh } from 'three'
|
import { type Group, MathUtils, type Mesh } from 'three'
|
||||||
import type { MeshBasicNodeMaterial } from 'three/webgpu'
|
import type { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import useInteractionScope from '../../../store/use-interaction-scope'
|
||||||
|
|
||||||
// Disable raycasting on zone geometry so clicks pass through to items underneath.
|
// Disable raycasting on zone geometry so clicks pass through to items underneath.
|
||||||
// Zone selection in the editor is handled exclusively via the HTML label overlay.
|
// Zone selection in the editor is handled exclusively via the HTML label overlay.
|
||||||
@@ -20,6 +22,11 @@ export const ZoneSystem = () => {
|
|||||||
// geometry or the HTML zone tags in the framed shot.
|
// geometry or the HTML zone tags in the framed shot.
|
||||||
const isCaptureMode = useEditor.getState().isCaptureMode
|
const isCaptureMode = useEditor.getState().isCaptureMode
|
||||||
|
|
||||||
|
// During any active interaction zone labels step back entirely — they are
|
||||||
|
// not a primary editing concern and would distract / invite misclicks.
|
||||||
|
const zoneLabelsHidden =
|
||||||
|
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
|
||||||
|
|
||||||
const zoneGeometryVisible = structureLayer === 'zones'
|
const zoneGeometryVisible = structureLayer === 'zones'
|
||||||
const zones = sceneRegistry.byType.zone || new Set()
|
const zones = sceneRegistry.byType.zone || new Set()
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
@@ -84,7 +91,8 @@ export const ZoneSystem = () => {
|
|||||||
|
|
||||||
// Labels: visible on the current level (regardless of mode), but never
|
// Labels: visible on the current level (regardless of mode), but never
|
||||||
// during snapshot capture.
|
// during snapshot capture.
|
||||||
const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel
|
const showLabel =
|
||||||
|
!isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
|
||||||
const labelOpacity = showLabel ? '1' : '0'
|
const labelOpacity = showLabel ? '1' : '0'
|
||||||
const labelEl = document.getElementById(`${zoneId}-label`)
|
const labelEl = document.getElementById(`${zoneId}-label`)
|
||||||
if (labelEl && labelEl.style.opacity !== labelOpacity) {
|
if (labelEl && labelEl.style.opacity !== labelOpacity) {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { type AssetInput, isObject } from '@pascal-app/core'
|
import { type AssetInput, isObject } from '@pascal-app/core'
|
||||||
import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
|
import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
|
||||||
|
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
|
// Sentinel returned when the active snapping mode disables grid snapping.
|
||||||
|
// The snap helpers below treat any `step <= 0` as "no grid snap" and pass the
|
||||||
|
// raw value through. When grid snapping is enabled (the default `'grid'` mode)
|
||||||
|
// this returns the user's `gridSnapStep` exactly as before — so the default
|
||||||
|
// path is byte-identical to the pre-mode behaviour.
|
||||||
function getGridSnapStep(): number {
|
function getGridSnapStep(): number {
|
||||||
return useEditor.getState().gridSnapStep
|
const state = useEditor.getState()
|
||||||
|
return resolveSnapFlags(state.snappingMode).grid ? state.gridSnapStep : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function positiveModulo(value: number, divisor: number): number {
|
function positiveModulo(value: number, divisor: number): number {
|
||||||
@@ -14,6 +21,7 @@ function positiveModulo(value: number, divisor: number): number {
|
|||||||
* Snaps a position to the active grid step, aligning item edges to grid lines.
|
* Snaps a position to the active grid step, aligning item edges to grid lines.
|
||||||
*/
|
*/
|
||||||
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
|
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
|
||||||
|
if (step <= 0) return position
|
||||||
const halfDim = dimension / 2
|
const halfDim = dimension / 2
|
||||||
const offset = positiveModulo(halfDim, step)
|
const offset = positiveModulo(halfDim, step)
|
||||||
return Math.round((position - offset) / step) * step + offset
|
return Math.round((position - offset) / step) * step + offset
|
||||||
@@ -23,6 +31,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
|
|||||||
* Snap a value to the active grid step (used for wall-local positions).
|
* Snap a value to the active grid step (used for wall-local positions).
|
||||||
*/
|
*/
|
||||||
export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
||||||
|
if (step <= 0) return value
|
||||||
return Math.round(value / step) * step
|
return Math.round(value / step) * step
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +39,7 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
|||||||
* Round a value up to the next multiple of `step`, with a minimum of `step`.
|
* Round a value up to the next multiple of `step`, with a minimum of `step`.
|
||||||
*/
|
*/
|
||||||
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
|
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
|
||||||
|
if (step <= 0) return value
|
||||||
return Math.max(step, Math.ceil(value / step) * step)
|
return Math.max(step, Math.ceil(value / step) * step)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
WallNode,
|
WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
|
canHostOnTop,
|
||||||
clampRectToRoofWallFace,
|
clampRectToRoofWallFace,
|
||||||
getRoofSegmentWallFace,
|
getRoofSegmentWallFace,
|
||||||
getScaledDimensions,
|
getScaledDimensions,
|
||||||
@@ -64,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
|
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
|
||||||
|
if (!canHostOnTop(surfaceItem)) return null
|
||||||
if (isLowProfileItemSurface(surfaceItem)) return null
|
if (isLowProfileItemSurface(surfaceItem)) return null
|
||||||
if (!isUpwardItemSurfaceHit(event)) return null
|
if (!isUpwardItemSurfaceHit(event)) return null
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ export const floorStrategy = {
|
|||||||
// is rotated; then project the world point back into building-local
|
// is rotated; then project the world point back into building-local
|
||||||
// for storage. Without this, a rotated building drags placement off
|
// for storage. Without this, a rotated building drags placement off
|
||||||
// the world grid.
|
// the world grid.
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const [x, z] = bypassSnap
|
const [x, z] = bypassSnap
|
||||||
? [event.localPosition[0], event.localPosition[2]]
|
? [event.localPosition[0], event.localPosition[2]]
|
||||||
: snapWorldXZForActiveBuilding(
|
: snapWorldXZForActiveBuilding(
|
||||||
@@ -202,7 +204,7 @@ export const wallStrategy = {
|
|||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||||
|
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
|
const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
|
||||||
const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
|
const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
|
||||||
const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
|
const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
|
||||||
@@ -266,7 +268,7 @@ export const wallStrategy = {
|
|||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||||
|
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
|
const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0])
|
||||||
const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
|
const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1])
|
||||||
const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
|
const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2])
|
||||||
@@ -393,14 +395,14 @@ type RoofWallTarget = {
|
|||||||
* `wall-side` items mount on the outer surface, `wall` items center in
|
* `wall-side` items mount on the outer surface, `wall` items center in
|
||||||
* the wall thickness.
|
* the wall thickness.
|
||||||
*
|
*
|
||||||
* `shiftFree` mirrors the wall flow's Shift override (stubbed
|
* `freePlace` mirrors the wall flow's Alt override (stubbed
|
||||||
* validators): the profile clamp is skipped, so the rect may overhang
|
* validators): the profile clamp is skipped, so the rect may overhang
|
||||||
* the face edges — placement follows the snapped cursor as-is.
|
* the face edges — placement follows the snapped cursor as-is.
|
||||||
*/
|
*/
|
||||||
function resolveRoofWallTarget(
|
function resolveRoofWallTarget(
|
||||||
ctx: PlacementContext,
|
ctx: PlacementContext,
|
||||||
event: RoofEvent,
|
event: RoofEvent,
|
||||||
shiftFree = false,
|
freePlace = false,
|
||||||
): RoofWallTarget | null {
|
): RoofWallTarget | null {
|
||||||
const attachTo = ctx.asset.attachTo
|
const attachTo = ctx.asset.attachTo
|
||||||
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
||||||
@@ -414,10 +416,10 @@ function resolveRoofWallTarget(
|
|||||||
const dims = getGridAlignedDimensions(rawDims, attachTo)
|
const dims = getGridAlignedDimensions(rawDims, attachTo)
|
||||||
const [width, height] = dims
|
const [width, height] = dims
|
||||||
|
|
||||||
const u = shiftFree ? hit.u : snapToHalf(hit.u)
|
const u = freePlace ? hit.u : snapToHalf(hit.u)
|
||||||
const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2
|
const centerV = (freePlace ? hit.v : snapToHalf(hit.v)) + height / 2
|
||||||
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
|
const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
|
||||||
if (!fitted && !shiftFree) return null
|
if (!fitted && !freePlace) return null
|
||||||
const finalU = fitted?.u ?? u
|
const finalU = fitted?.u ?? u
|
||||||
const finalV = fitted?.v ?? centerV
|
const finalV = fitted?.v ?? centerV
|
||||||
|
|
||||||
@@ -483,8 +485,8 @@ export const roofWallStrategy = {
|
|||||||
* face. Returns null when the item doesn't wall-attach or the pointer
|
* face. Returns null when the item doesn't wall-attach or the pointer
|
||||||
* isn't over a placeable face.
|
* isn't over a placeable face.
|
||||||
*/
|
*/
|
||||||
enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null {
|
enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null {
|
||||||
const target = resolveRoofWallTarget(ctx, event, shiftFree)
|
const target = resolveRoofWallTarget(ctx, event, freePlace)
|
||||||
if (!target) return null
|
if (!target) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -511,11 +513,11 @@ export const roofWallStrategy = {
|
|||||||
* segment transitions inside one roof never re-fire roof:enter) or to
|
* segment transitions inside one roof never re-fire roof:enter) or to
|
||||||
* no placeable face.
|
* no placeable face.
|
||||||
*/
|
*/
|
||||||
move(ctx: PlacementContext, event: RoofEvent, shiftFree = false): PlacementResult | null {
|
move(ctx: PlacementContext, event: RoofEvent, freePlace = false): PlacementResult | null {
|
||||||
if (ctx.state.surface !== 'roof-wall') return null
|
if (ctx.state.surface !== 'roof-wall') return null
|
||||||
if (!ctx.draftItem) return null
|
if (!ctx.draftItem) return null
|
||||||
|
|
||||||
const target = resolveRoofWallTarget(ctx, event, shiftFree)
|
const target = resolveRoofWallTarget(ctx, event, freePlace)
|
||||||
if (!target) return null
|
if (!target) return null
|
||||||
if (target.segment.id !== ctx.state.roofSegmentId) return null
|
if (target.segment.id !== ctx.state.roofSegmentId) return null
|
||||||
|
|
||||||
@@ -538,12 +540,12 @@ export const roofWallStrategy = {
|
|||||||
/**
|
/**
|
||||||
* Handle roof:click — commit placement on the segment wall face.
|
* Handle roof:click — commit placement on the segment wall face.
|
||||||
*/
|
*/
|
||||||
click(ctx: PlacementContext, _event: RoofEvent, shiftFree = false): CommitResult | null {
|
click(ctx: PlacementContext, _event: RoofEvent, freePlace = false): CommitResult | null {
|
||||||
if (ctx.state.surface !== 'roof-wall') return null
|
if (ctx.state.surface !== 'roof-wall') return null
|
||||||
if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
|
if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
|
||||||
// Shift mirrors the wall flow's stubbed validators: skip profile-fit
|
// Alt mirrors the wall flow's stubbed validators: skip profile-fit
|
||||||
// and overlap checks entirely.
|
// and overlap checks entirely.
|
||||||
if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null
|
if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodeUpdate: {
|
nodeUpdate: {
|
||||||
@@ -615,7 +617,7 @@ export const ceilingStrategy = {
|
|||||||
|
|
||||||
// Ceiling items are stored in ceiling-local coordinates, so snapping must
|
// Ceiling items are stored in ceiling-local coordinates, so snapping must
|
||||||
// use the ceiling hit's local position rather than world position.
|
// use the ceiling hit's local position rather than world position.
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const x = bypassSnap
|
const x = bypassSnap
|
||||||
? event.localPosition[0]
|
? event.localPosition[0]
|
||||||
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||||
@@ -654,7 +656,7 @@ export const ceilingStrategy = {
|
|||||||
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
||||||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||||||
|
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const x = bypassSnap
|
const x = bypassSnap
|
||||||
? event.localPosition[0]
|
? event.localPosition[0]
|
||||||
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
: snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
|
||||||
@@ -771,7 +773,7 @@ export const itemSurfaceStrategy = {
|
|||||||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||||||
if (surfaceHeight === null) return null
|
if (surfaceHeight === null) return null
|
||||||
|
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||||
const y = surfaceHeight
|
const y = surfaceHeight
|
||||||
@@ -823,7 +825,7 @@ export const itemSurfaceStrategy = {
|
|||||||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||||||
if (surfaceHeight === null) return null
|
if (surfaceHeight === null) return null
|
||||||
|
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||||
const y = surfaceHeight
|
const y = surfaceHeight
|
||||||
@@ -924,7 +926,7 @@ export const shelfSurfaceStrategy = {
|
|||||||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||||||
if (rowY === null) return null
|
if (rowY === null) return null
|
||||||
|
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||||
|
|
||||||
@@ -969,7 +971,7 @@ export const shelfSurfaceStrategy = {
|
|||||||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||||||
if (rowY === null) return null
|
if (rowY === null) return null
|
||||||
|
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0])
|
||||||
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2])
|
||||||
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
|
|||||||
import { formatLinearMeasurement } from '../../../lib/measurements'
|
import { formatLinearMeasurement } from '../../../lib/measurements'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
|
||||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||||
import {
|
import {
|
||||||
createLineGeometry,
|
createLineGeometry,
|
||||||
@@ -221,7 +221,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
shelfId: null,
|
shelfId: null,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const shiftFreeRef = useRef(false)
|
const altFreeRef = useRef(false)
|
||||||
const previewBoundsSignatureRef = useRef<string | null>(null)
|
const previewBoundsSignatureRef = useRef<string | null>(null)
|
||||||
// Goes true the first time a 3D pointer event drives this coordinator.
|
// Goes true the first time a 3D pointer event drives this coordinator.
|
||||||
// The per-frame mesh-position lerp below is only useful for that path;
|
// The per-frame mesh-position lerp below is only useful for that path;
|
||||||
@@ -441,7 +441,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getActiveValidators = () =>
|
const getActiveValidators = () =>
|
||||||
shiftFreeRef.current
|
altFreeRef.current
|
||||||
? {
|
? {
|
||||||
canPlaceOnFloor: () => ({ valid: true }),
|
canPlaceOnFloor: () => ({ valid: true }),
|
||||||
canPlaceOnWall: () => ({ valid: true }),
|
canPlaceOnWall: () => ({ valid: true }),
|
||||||
@@ -450,7 +450,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
: validators
|
: validators
|
||||||
|
|
||||||
const revalidate = (): boolean => {
|
const revalidate = (): boolean => {
|
||||||
const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
|
const placeable = altFreeRef.current || checkCanPlace(getContext(), validators)
|
||||||
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
||||||
edgeMaterial.color.setHex(color)
|
edgeMaterial.color.setHex(color)
|
||||||
basePlaneMaterial.color.setHex(color)
|
basePlaneMaterial.color.setHex(color)
|
||||||
@@ -610,8 +610,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// Floor grab-offset: the item tracks the grabbed point instead of snapping
|
// Floor grab-offset: the item tracks the grabbed point instead of snapping
|
||||||
// its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid
|
// its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid
|
||||||
// (`event.position`) on its default path and only reads `event.localPosition`
|
// (`event.position`) on its default path and only reads `event.localPosition`
|
||||||
// under Shift, so both frames must carry the offset; the world point is
|
// under Alt (free place), so both frames must carry the offset; the world
|
||||||
// derived from the corrected local one so the two stay consistent.
|
// point is derived from the corrected local one so the two stay consistent.
|
||||||
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
|
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
|
||||||
if (relativeFloorStart === null) return event
|
if (relativeFloorStart === null) return event
|
||||||
const rawX = event.localPosition[0]
|
const rawX = event.localPosition[0]
|
||||||
@@ -773,12 +773,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// item's edge, snap and publish a guide. The guide connects to the
|
// item's edge, snap and publish a guide. The guide connects to the
|
||||||
// nearest real corner of the candidate (resolver tie-break), so the dot
|
// nearest real corner of the candidate (resolver tie-break), so the dot
|
||||||
// always sits on an actual point. The delta is applied to BOTH the grid
|
// always sits on an actual point. The delta is applied to BOTH the grid
|
||||||
// and cursor positions below. Alt bypasses alignment; Shift bypasses all snap.
|
// and cursor positions below. Alt (free place) bypasses all snap; the
|
||||||
|
// active snapping mode governs whether alignment runs at all ('off' /
|
||||||
|
// 'angles' disable magnetic alignment, matching the wall/fence flow).
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
let alignX = 0
|
let alignX = 0
|
||||||
let alignZ = 0
|
let alignZ = 0
|
||||||
const bypassSnap = floorEvent.nativeEvent?.shiftKey === true
|
const freePlace = floorEvent.nativeEvent?.altKey === true
|
||||||
const bypassAlign = floorEvent.nativeEvent?.altKey === true || bypassSnap
|
const bypassAlign = freePlace || !isMagneticSnapActive()
|
||||||
if (!bypassAlign && draft) {
|
if (!bypassAlign && draft) {
|
||||||
alignmentCandidates ??= collectAlignmentAnchors(
|
alignmentCandidates ??= collectAlignmentAnchors(
|
||||||
useScene.getState().nodes,
|
useScene.getState().nodes,
|
||||||
@@ -812,7 +814,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
// Play snap sound when grid position changes
|
// Play snap sound when grid position changes
|
||||||
if (
|
if (
|
||||||
!bypassSnap &&
|
!freePlace &&
|
||||||
previousGridPos &&
|
previousGridPos &&
|
||||||
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
|
(gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2])
|
||||||
) {
|
) {
|
||||||
@@ -997,7 +999,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
gridPosition.current.z !== result.gridPosition[2]
|
gridPosition.current.z !== result.gridPosition[2]
|
||||||
|
|
||||||
// Play snap sound when grid position changes
|
// Play snap sound when grid position changes
|
||||||
if (event.nativeEvent?.shiftKey !== true && posChanged) {
|
if (event.nativeEvent?.altKey !== true && posChanged) {
|
||||||
sfxEmitter.emit('sfx:grid-snap')
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,7 +1123,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// re-enters whenever the strategy reports a segment change.
|
// re-enters whenever the strategy reports a segment change.
|
||||||
|
|
||||||
const enterRoofWall = (event: RoofEvent): boolean => {
|
const enterRoofWall = (event: RoofEvent): boolean => {
|
||||||
const result = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
|
const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
||||||
if (!result) return false
|
if (!result) return false
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -1152,7 +1154,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current)
|
const result = roofWallStrategy.move(ctx, event, altFreeRef.current)
|
||||||
if (!result) {
|
if (!result) {
|
||||||
// Different segment under the pointer (or no placeable face) —
|
// Different segment under the pointer (or no placeable face) —
|
||||||
// try a fresh enter; a null resolve leaves the draft where it is.
|
// try a fresh enter; a null resolve leaves the draft where it is.
|
||||||
@@ -1167,7 +1169,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
gridPosition.current.y !== result.gridPosition[1] ||
|
gridPosition.current.y !== result.gridPosition[1] ||
|
||||||
gridPosition.current.z !== result.gridPosition[2]
|
gridPosition.current.z !== result.gridPosition[2]
|
||||||
|
|
||||||
if (!shiftFreeRef.current && posChanged) {
|
if (!altFreeRef.current && posChanged) {
|
||||||
sfxEmitter.emit('sfx:grid-snap')
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,7 +1212,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofWallClick = (event: RoofEvent) => {
|
const onRoofWallClick = (event: RoofEvent) => {
|
||||||
const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current)
|
const result = roofWallStrategy.click(getContext(), event, altFreeRef.current)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -1220,7 +1222,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
|
|
||||||
if (configRef.current.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
|
const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current)
|
||||||
if (enterResult) {
|
if (enterResult) {
|
||||||
applyTransition(enterResult)
|
applyTransition(enterResult)
|
||||||
} else {
|
} else {
|
||||||
@@ -1261,7 +1263,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
event.position[1],
|
event.position[1],
|
||||||
event.position[2],
|
event.position[2],
|
||||||
)
|
)
|
||||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
const bypassSnap = event.nativeEvent?.altKey === true
|
||||||
const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2
|
const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2
|
||||||
const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2
|
const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2
|
||||||
const floorPos: [number, number, number] = [wx, 0, wz]
|
const floorPos: [number, number, number] = [wx, 0, wz]
|
||||||
@@ -1598,7 +1600,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
gridPosition.current.y !== result.gridPosition[1] ||
|
gridPosition.current.y !== result.gridPosition[1] ||
|
||||||
gridPosition.current.z !== result.gridPosition[2]
|
gridPosition.current.z !== result.gridPosition[2]
|
||||||
|
|
||||||
if (event.nativeEvent?.shiftKey !== true && posChanged) {
|
if (event.nativeEvent?.altKey !== true && posChanged) {
|
||||||
sfxEmitter.emit('sfx:grid-snap')
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1793,8 +1795,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
|
// items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
|
||||||
const ROTATION_STEP = Math.PI / 4
|
const ROTATION_STEP = Math.PI / 4
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Shift') {
|
if (event.key === 'Alt') {
|
||||||
shiftFreeRef.current = true
|
altFreeRef.current = true
|
||||||
revalidate()
|
revalidate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1908,8 +1910,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Shift') {
|
if (event.key === 'Alt') {
|
||||||
shiftFreeRef.current = false
|
altFreeRef.current = false
|
||||||
revalidate()
|
revalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1997,6 +1999,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
emitter.on('shelf:move', onShelfMove)
|
emitter.on('shelf:move', onShelfMove)
|
||||||
emitter.on('shelf:click', onShelfClick)
|
emitter.on('shelf:click', onShelfClick)
|
||||||
emitter.on('shelf:leave', onShelfLeave)
|
emitter.on('shelf:leave', onShelfLeave)
|
||||||
|
|
||||||
|
// A floor placement commits at the tracked floor cursor (`gridPosition`),
|
||||||
|
// which keeps following the floor even when the click ray lands on a wall
|
||||||
|
// (grid:move uses a separate ground-plane raycast). Without this, a commit
|
||||||
|
// click whose ray hits a wall fires only `wall:click` — whose handler
|
||||||
|
// declines for a floor item — and the click is silently eaten (the user
|
||||||
|
// has to click again until the ray happens to clear the wall). Route every
|
||||||
|
// surface click to the floor commit too; `floorStrategy.click` guards on
|
||||||
|
// `surface === 'floor'` (and a non-attach draft), so it no-ops while the
|
||||||
|
// draft is actually resting on that surface.
|
||||||
|
const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => {
|
||||||
|
if (placementState.current.surface !== 'floor') return
|
||||||
|
onGridClick(event as unknown as GridEvent)
|
||||||
|
}
|
||||||
|
emitter.on('wall:click', commitFloorOnSurfaceClick as never)
|
||||||
|
emitter.on('item:click', commitFloorOnSurfaceClick as never)
|
||||||
|
emitter.on('ceiling:click', commitFloorOnSurfaceClick as never)
|
||||||
|
emitter.on('roof:click', commitFloorOnSurfaceClick as never)
|
||||||
|
emitter.on('shelf:click', commitFloorOnSurfaceClick as never)
|
||||||
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
|
if (dragMode) window.addEventListener('pointerup', onReleaseCommit)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -2032,6 +2053,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
emitter.off('shelf:move', onShelfMove)
|
emitter.off('shelf:move', onShelfMove)
|
||||||
emitter.off('shelf:click', onShelfClick)
|
emitter.off('shelf:click', onShelfClick)
|
||||||
emitter.off('shelf:leave', onShelfLeave)
|
emitter.off('shelf:leave', onShelfLeave)
|
||||||
|
emitter.off('wall:click', commitFloorOnSurfaceClick as never)
|
||||||
|
emitter.off('item:click', commitFloorOnSurfaceClick as never)
|
||||||
|
emitter.off('ceiling:click', commitFloorOnSurfaceClick as never)
|
||||||
|
emitter.off('roof:click', commitFloorOnSurfaceClick as never)
|
||||||
|
emitter.off('shelf:click', commitFloorOnSurfaceClick as never)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
window.removeEventListener('keyup', onKeyUp)
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
@@ -2114,7 +2140,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
|
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
|
||||||
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
|
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame(() => {
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
reconcileDraftRaycast(null)
|
reconcileDraftRaycast(null)
|
||||||
return
|
return
|
||||||
@@ -2145,12 +2171,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
mesh.visible = true
|
mesh.visible = true
|
||||||
|
|
||||||
if (placementState.current.surface === 'floor') {
|
if (placementState.current.surface === 'floor') {
|
||||||
const distance = mesh.position.distanceToSquared(gridPosition.current)
|
// Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an
|
||||||
if (distance > 1) {
|
// active move visibly trail the cursor and — combined with React
|
||||||
mesh.position.copy(gridPosition.current)
|
// re-renders momentarily pulling the mesh back toward its committed
|
||||||
} else {
|
// position — read as a laggy snap-back on every move. Copying each frame
|
||||||
mesh.position.lerp(gridPosition.current, delta * 20)
|
// locks placement/move to the cursor and overrides any stray reset
|
||||||
}
|
// within a single frame, so it feels precise instead of dragging.
|
||||||
|
mesh.position.copy(gridPosition.current)
|
||||||
|
|
||||||
// Adjust Y for slab elevation (floor items on top of slabs)
|
// Adjust Y for slab elevation (floor items on top of slabs)
|
||||||
if (!asset.attachTo) {
|
if (!asset.attachTo) {
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement
|
|||||||
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
|
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
|
||||||
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||||
|
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
|
||||||
import { swallowNextClick } from '../../editor/node-arrow-handles'
|
import { swallowNextClick } from '../../editor/node-arrow-handles'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
import { DragBoundingBox } from '../shared/drag-bounding-box'
|
import { DragBoundingBox } from '../shared/drag-bounding-box'
|
||||||
@@ -41,7 +42,9 @@ import { PlacementBox } from '../shared/placement-box'
|
|||||||
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
||||||
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
|
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
|
||||||
const snapToGridStep = (value: number) => {
|
const snapToGridStep = (value: number) => {
|
||||||
const step = useEditor.getState().gridSnapStep
|
const state = useEditor.getState()
|
||||||
|
if (!resolveSnapFlags(state.snappingMode).grid) return value
|
||||||
|
const step = state.gridSnapStep
|
||||||
return Math.round(value / step) * step
|
return Math.round(value / step) * step
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,10 +247,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
// a register collar drops onto a duct run end. Reads `def.ports` through
|
// a register collar drops onto a duct run end. Reads `def.ports` through
|
||||||
// the core registry, so it stays layer-clean (no @pascal-app/nodes import).
|
// the core registry, so it stays layer-clean (no @pascal-app/nodes import).
|
||||||
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
|
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
|
||||||
// Mirrors of `valid` / Shift for the event handlers inside the effect, which
|
// Mirrors of `valid` / Alt for the event handlers inside the effect, which
|
||||||
// can't read React state without stale closures.
|
// can't read React state without stale closures.
|
||||||
const validRef = useRef(true)
|
const validRef = useRef(true)
|
||||||
const shiftRef = useRef(false)
|
const altRef = useRef(false)
|
||||||
|
|
||||||
const exitMoveMode = useCallback(() => {
|
const exitMoveMode = useCallback(() => {
|
||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
@@ -259,7 +262,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
dragAnchorRef.current = null
|
dragAnchorRef.current = null
|
||||||
hasMovedRef.current = false
|
hasMovedRef.current = false
|
||||||
rotationRef.current = originalRotationY
|
rotationRef.current = originalRotationY
|
||||||
shiftRef.current = false
|
altRef.current = false
|
||||||
validRef.current = true
|
validRef.current = true
|
||||||
// Re-sync the box transform to the (possibly new) node. `node` changes
|
// Re-sync the box transform to the (possibly new) node. `node` changes
|
||||||
// without this component remounting whenever a positioned preset re-arms a
|
// without this component remounting whenever a positioned preset re-arms a
|
||||||
@@ -335,12 +338,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
||||||
|
|
||||||
// Re-run the floor-collision check at the live cursor + rotation and push
|
// Re-run the floor-collision check at the live cursor + rotation and push
|
||||||
// the result to the box colour. Shift forces a valid (green) override so
|
// the result to the box colour. Alt (free place) forces a valid (green)
|
||||||
// the user can drop on top of an existing item on purpose. Only shelves
|
// override so the user can drop on top of an existing item on purpose. Only
|
||||||
// show the box, so this no-ops for every other movable kind.
|
// shelves show the box, so this no-ops for every other movable kind.
|
||||||
const recomputeValidity = () => {
|
const recomputeValidity = () => {
|
||||||
if (!boxDimensions) return
|
if (!boxDimensions) return
|
||||||
if (shiftRef.current) {
|
if (altRef.current) {
|
||||||
validRef.current = true
|
validRef.current = true
|
||||||
setValid(true)
|
setValid(true)
|
||||||
return
|
return
|
||||||
@@ -417,7 +420,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
original: [originalPosition[0], originalPosition[2]],
|
original: [originalPosition[0], originalPosition[2]],
|
||||||
anchor: dragAnchorRef.current,
|
anchor: dragAnchorRef.current,
|
||||||
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
|
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
|
||||||
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
|
snap: event.nativeEvent?.altKey === true ? (value) => value : snapToGridStep,
|
||||||
})
|
})
|
||||||
dragAnchorRef.current = resolved.anchor
|
dragAnchorRef.current = resolved.anchor
|
||||||
let [x, z] = resolved.point
|
let [x, z] = resolved.point
|
||||||
@@ -426,8 +429,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
// moving item's edge lines up (on X or Z) with another item's edge,
|
// moving item's edge lines up (on X or Z) with another item's edge,
|
||||||
// snap and publish a guide. The guide connects to the nearest real
|
// snap and publish a guide. The guide connects to the nearest real
|
||||||
// corner of the candidate (resolver tie-break), so the dot always sits
|
// corner of the candidate (resolver tie-break), so the dot always sits
|
||||||
// on an actual point. Alt bypasses alignment; Shift bypasses all snap.
|
// on an actual point. Alt (free place) bypasses all snap; the active
|
||||||
const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true
|
// snapping mode governs whether magnetic alignment runs at all.
|
||||||
|
const freePlace = event.nativeEvent?.altKey === true
|
||||||
|
const bypass = freePlace || !isMagneticSnapActive()
|
||||||
if (!bypass && alignmentCandidates.length > 0) {
|
if (!bypass && alignmentCandidates.length > 0) {
|
||||||
const result = resolveAlignment({
|
const result = resolveAlignment({
|
||||||
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
|
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
|
||||||
@@ -488,7 +493,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
previewConnectivity(position, rotationRef.current)
|
previewConnectivity(position, rotationRef.current)
|
||||||
|
|
||||||
const prev = previousSnapRef.current
|
const prev = previousSnapRef.current
|
||||||
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) {
|
if (!freePlace && (!prev || prev[0] !== x || prev[1] !== z)) {
|
||||||
sfxEmitter.emit('sfx:grid-snap')
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
previousSnapRef.current = [x, z]
|
previousSnapRef.current = [x, z]
|
||||||
}
|
}
|
||||||
@@ -524,9 +529,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
// deliberate drop. Prevents preset re-arm from double-placing.
|
// deliberate drop. Prevents preset re-arm from double-placing.
|
||||||
if (!hasMovedRef.current) return
|
if (!hasMovedRef.current) return
|
||||||
// Refuse a drop on an invalid (red) footprint, matching the GLB item
|
// Refuse a drop on an invalid (red) footprint, matching the GLB item
|
||||||
// tool — unless Shift is held to force placement. Other kinds carry no
|
// tool — unless Alt (free place) is held to force placement. Other kinds
|
||||||
// validity box (`validRef` stays true), so they're never blocked.
|
// carry no validity box (`validRef` stays true), so they're never blocked.
|
||||||
if (!validRef.current && !shiftRef.current) return
|
if (!validRef.current && !altRef.current) return
|
||||||
const position: [number, number, number] = [...lastCursorRef.current]
|
const position: [number, number, number] = [...lastCursorRef.current]
|
||||||
|
|
||||||
const rotation = toCommitRotation(rotationRef.current)
|
const rotation = toCommitRotation(rotationRef.current)
|
||||||
@@ -624,10 +629,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
// item placement keys (and the "Rotate" hints the move HUD shows). Applied
|
// item placement keys (and the "Rotate" hints the move HUD shows). Applied
|
||||||
// imperatively + mirrored to the live transform; committed on drop.
|
// imperatively + mirrored to the live transform; committed on drop.
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
// Hold Shift to force placement on an invalid (red) footprint, matching
|
// Hold Alt (free place) to force placement on an invalid (red) footprint,
|
||||||
// the GLB item tool. Recolour the box to green while held.
|
// matching the GLB item tool. Recolour the box to green while held.
|
||||||
if (e.key === 'Shift') {
|
if (e.key === 'Alt') {
|
||||||
shiftRef.current = true
|
altRef.current = true
|
||||||
recomputeValidity()
|
recomputeValidity()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -659,8 +664,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
recomputeValidity()
|
recomputeValidity()
|
||||||
}
|
}
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Shift') {
|
if (e.key === 'Alt') {
|
||||||
shiftRef.current = false
|
altRef.current = false
|
||||||
recomputeValidity()
|
recomputeValidity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useThree } from '@react-three/fiber'
|
|||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { Box3, type Camera, type Object3D, Vector3 } from 'three'
|
import { Box3, type Camera, type Object3D, Vector3 } from 'three'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import useInteractionScope from '../../../store/use-interaction-scope'
|
||||||
import {
|
import {
|
||||||
clearBoxSelectHandled,
|
clearBoxSelectHandled,
|
||||||
isBoxSelectPointerSuppressed,
|
isBoxSelectPointerSuppressed,
|
||||||
@@ -191,6 +192,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|||||||
const currentClientXRef = useRef(0)
|
const currentClientXRef = useRef(0)
|
||||||
const currentClientYRef = useRef(0)
|
const currentClientYRef = useRef(0)
|
||||||
const spaceDownRef = useRef(false)
|
const spaceDownRef = useRef(false)
|
||||||
|
// rAF throttle for the expensive marquee preview pass. pointermove can fire
|
||||||
|
// several times per animation frame; the per-node AABB projection in
|
||||||
|
// `collectNodeIdsInScreenRect` only needs to run once per frame. We stash the
|
||||||
|
// latest clamped rect and process it inside the rAF callback.
|
||||||
|
const previewRafRef = useRef<number | null>(null)
|
||||||
|
const pendingPreviewRectRef = useRef<ScreenRect | null>(null)
|
||||||
|
|
||||||
const syncPreviewSelectedIds = useCallback(
|
const syncPreviewSelectedIds = useCallback(
|
||||||
(nextIds: string[]) => {
|
(nextIds: string[]) => {
|
||||||
@@ -206,6 +213,11 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|||||||
pointerDownRef.current = false
|
pointerDownRef.current = false
|
||||||
isDraggingRef.current = false
|
isDraggingRef.current = false
|
||||||
pointerIdRef.current = null
|
pointerIdRef.current = null
|
||||||
|
if (previewRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(previewRafRef.current)
|
||||||
|
previewRafRef.current = null
|
||||||
|
}
|
||||||
|
pendingPreviewRectRef.current = null
|
||||||
hideScreenRectangleSelectionElement(elementRef.current)
|
hideScreenRectangleSelectionElement(elementRef.current)
|
||||||
syncPreviewSelectedIds([])
|
syncPreviewSelectedIds([])
|
||||||
|
|
||||||
@@ -213,6 +225,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|||||||
useViewer.getState().setInputDragging(false)
|
useViewer.getState().setInputDragging(false)
|
||||||
ownsInputDraggingRef.current = false
|
ownsInputDraggingRef.current = false
|
||||||
}
|
}
|
||||||
|
useInteractionScope.getState().endIf((s) => s.kind === 'box-select')
|
||||||
}, [syncPreviewSelectedIds])
|
}, [syncPreviewSelectedIds])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -263,6 +276,14 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = gl.domElement
|
const canvas = gl.domElement
|
||||||
|
|
||||||
|
const flushPreview = () => {
|
||||||
|
previewRafRef.current = null
|
||||||
|
const rect = pendingPreviewRectRef.current
|
||||||
|
if (!rect) return
|
||||||
|
pendingPreviewRectRef.current = null
|
||||||
|
syncPreviewSelectedIds(collectNodeIdsInScreenRect(rect, camera, canvas))
|
||||||
|
}
|
||||||
|
|
||||||
const updateDrag = (event: PointerEvent) => {
|
const updateDrag = (event: PointerEvent) => {
|
||||||
if (!pointerDownRef.current) return
|
if (!pointerDownRef.current) return
|
||||||
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
|
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
|
||||||
@@ -291,6 +312,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|||||||
isDraggingRef.current = true
|
isDraggingRef.current = true
|
||||||
ownsInputDraggingRef.current = true
|
ownsInputDraggingRef.current = true
|
||||||
useViewer.getState().setInputDragging(true)
|
useViewer.getState().setInputDragging(true)
|
||||||
|
useInteractionScope.getState().begin({ kind: 'box-select' })
|
||||||
markBoxSelectHandled()
|
markBoxSelectHandled()
|
||||||
try {
|
try {
|
||||||
canvas.setPointerCapture(event.pointerId)
|
canvas.setPointerCapture(event.pointerId)
|
||||||
@@ -311,13 +333,22 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|||||||
screenRectFromDomRect(canvas.getBoundingClientRect()),
|
screenRectFromDomRect(canvas.getBoundingClientRect()),
|
||||||
)
|
)
|
||||||
if (!clampedRect) {
|
if (!clampedRect) {
|
||||||
|
if (previewRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(previewRafRef.current)
|
||||||
|
previewRafRef.current = null
|
||||||
|
}
|
||||||
|
pendingPreviewRectRef.current = null
|
||||||
hideScreenRectangleSelectionElement(elementRef.current)
|
hideScreenRectangleSelectionElement(elementRef.current)
|
||||||
syncPreviewSelectedIds([])
|
syncPreviewSelectedIds([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
|
updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
|
||||||
syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas))
|
// Coalesce the per-node AABB projection to one run per animation frame.
|
||||||
|
pendingPreviewRectRef.current = clampedRect
|
||||||
|
if (previewRafRef.current === null) {
|
||||||
|
previewRafRef.current = requestAnimationFrame(flushPreview)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const finishDrag = (event: PointerEvent) => {
|
const finishDrag = (event: PointerEvent) => {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
|
||||||
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
||||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||||
const curvingWall = useEditor((state) => state.curvingWall)
|
const curvingWall = useEditor((state) => state.curvingWall)
|
||||||
@@ -134,6 +135,16 @@ export const ToolManager: React.FC = () => {
|
|||||||
// Show build tools when in build mode
|
// Show build tools when in build mode
|
||||||
const showBuildTool = mode === 'build' && tool !== null
|
const showBuildTool = mode === 'build' && tool !== null
|
||||||
|
|
||||||
|
// A move initiated from the 2D floor-plan (orange move-dot) is owned end-to-
|
||||||
|
// end by `FloorplanRegistryMoveOverlay`, which marks the origin `'2d'` at
|
||||||
|
// dot-down. Mounting the 3D affordance mover alongside it would adopt the
|
||||||
|
// same node and, on its unmount, restore the adopt-time position — snapping
|
||||||
|
// the committed 2D move back to its start. Gate the 3D mover off for 2D moves
|
||||||
|
// (the scene writes the overlay makes still mirror into the 3D view). A
|
||||||
|
// 3D-initiated move leaves the origin null until its own commit, so this only
|
||||||
|
// suppresses the 3D tool for genuinely 2D-owned moves.
|
||||||
|
const showMover = movingNode != null && movingNodeOrigin !== '2d'
|
||||||
|
|
||||||
// Registry-first: if the active tool's kind has a NodeDefinition with a
|
// Registry-first: if the active tool's kind has a NodeDefinition with a
|
||||||
// tool contribution, the registry-driven tool takes over.
|
// tool contribution, the registry-driven tool takes over.
|
||||||
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
|
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
|
||||||
@@ -163,7 +174,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
||||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||||
{movingNode?.type === 'building' && (
|
{showMover && movingNode?.type === 'building' && (
|
||||||
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
|
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -259,7 +270,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null
|
) : null
|
||||||
})()}
|
})()}
|
||||||
{movingNode && movingNode.type !== 'building' && (
|
{showMover && movingNode.type !== 'building' && (
|
||||||
<MoveTool
|
<MoveTool
|
||||||
onNodeMoved={handlePlacedNodeSelected}
|
onNodeMoved={handlePlacedNodeSelected}
|
||||||
onSpawnMoved={handlePlacedNodeSelected}
|
onSpawnMoved={handlePlacedNodeSelected}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||||
|
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
|
||||||
import {
|
import {
|
||||||
distanceSquared,
|
distanceSquared,
|
||||||
findWallSnapTarget,
|
findWallSnapTarget,
|
||||||
@@ -51,10 +52,16 @@ type WallSplitIntersection = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSegmentGridStep(): number {
|
export function getSegmentGridStep(): number {
|
||||||
return useEditor.getState().gridSnapStep
|
const state = useEditor.getState()
|
||||||
|
// A 0 step means "no grid lattice" — every grid-snap consumer guards on
|
||||||
|
// `step <= 0` and returns the raw value, so disabling grid here suppresses
|
||||||
|
// the lattice for walls, fences, and every node move/affordance that reads
|
||||||
|
// this choke point, without retuning their snap math.
|
||||||
|
return resolveSnapFlags(state.snappingMode).grid ? state.gridSnapStep : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
||||||
|
if (step <= 0) return value
|
||||||
return Math.round(value / step) * step
|
return Math.round(value / step) * step
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,32 +411,38 @@ export function createWallOnCurrentLevel(
|
|||||||
let resolvedStart = start
|
let resolvedStart = start
|
||||||
let resolvedEnd = end
|
let resolvedEnd = end
|
||||||
|
|
||||||
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
|
// The corner-join / wall-split snap on commit is a magnetic (line) snap, so
|
||||||
const splitEnd = splitWallIfNeeded(
|
// it must be gated by the snapping mode like the draft preview is. Without
|
||||||
endIntersection,
|
// this gate `'off'` (and `'angles'`) still snapped the committed endpoint to
|
||||||
workingWalls,
|
// existing wall geometry — the residual snap the draft path no longer does.
|
||||||
nodes,
|
if (isMagneticSnapActive()) {
|
||||||
createNodes,
|
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
|
||||||
updateNodes,
|
const splitEnd = splitWallIfNeeded(
|
||||||
deleteNode,
|
endIntersection,
|
||||||
)
|
workingWalls,
|
||||||
if (splitEnd) {
|
nodes,
|
||||||
workingWalls = splitEnd.walls
|
createNodes,
|
||||||
resolvedEnd = splitEnd.point
|
updateNodes,
|
||||||
}
|
deleteNode,
|
||||||
|
)
|
||||||
|
if (splitEnd) {
|
||||||
|
workingWalls = splitEnd.walls
|
||||||
|
resolvedEnd = splitEnd.point
|
||||||
|
}
|
||||||
|
|
||||||
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
|
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
|
||||||
const splitStart = splitWallIfNeeded(
|
const splitStart = splitWallIfNeeded(
|
||||||
startIntersection,
|
startIntersection,
|
||||||
workingWalls,
|
workingWalls,
|
||||||
nodes,
|
nodes,
|
||||||
createNodes,
|
createNodes,
|
||||||
updateNodes,
|
updateNodes,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
)
|
)
|
||||||
if (splitStart) {
|
if (splitStart) {
|
||||||
workingWalls = splitStart.walls
|
workingWalls = splitStart.walls
|
||||||
resolvedStart = splitStart.point
|
resolvedStart = splitStart.point
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
|
if (!isSegmentLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
|
||||||
|
|||||||
@@ -1,35 +1,129 @@
|
|||||||
|
import { Icon } from '@iconify/react'
|
||||||
import type { ContextualShortcutHint } from '../../../lib/contextual-help'
|
import type { ContextualShortcutHint } from '../../../lib/contextual-help'
|
||||||
|
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
||||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
||||||
|
|
||||||
|
const PILL_CLASS =
|
||||||
|
'flex items-center gap-3 rounded-full border border-border bg-popover/90 py-1.5 pr-1.5 pl-3.5 text-foreground text-[11px] shadow-md shadow-black/10 backdrop-blur-md'
|
||||||
|
|
||||||
function ShortcutSequence({ keys }: { keys: string[] }) {
|
function ShortcutSequence({ keys }: { keys: string[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{keys.map((key, index) => (
|
{keys.map((key, index) => (
|
||||||
<div className="flex items-center gap-0.5" key={`${key}-${index}`}>
|
<div className="flex items-center gap-1" key={`${key}-${index}`}>
|
||||||
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">+</span> : null}
|
{index > 0 ? <span className="text-[9px] text-muted-foreground/70">+</span> : null}
|
||||||
<ShortcutToken className="h-5 px-1.5 text-[10px]" value={key} />
|
<ShortcutToken className="h-6 px-1.5 text-[10px]" value={key} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) {
|
const SNAPPING_MODE_ICONS = {
|
||||||
if (hints.length === 0) return null
|
grid: 'lucide:grid-2x2',
|
||||||
|
lines: 'lucide:magnet',
|
||||||
|
angles: 'lucide:triangle',
|
||||||
|
off: 'lucide:ban',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const SNAPPING_MODE_LABELS = {
|
||||||
|
grid: 'Grid',
|
||||||
|
lines: 'Lines',
|
||||||
|
angles: 'Angles',
|
||||||
|
off: 'Off',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
||||||
|
|
||||||
|
function nextGridSnapStep(step: GridSnapStep): GridSnapStep {
|
||||||
|
const index = GRID_SNAP_STEPS.indexOf(step)
|
||||||
|
return GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive chip rows: the active interaction's own snapping controls. The
|
||||||
|
// surrounding stack is `pointer-events-none` (passive key hints), so these
|
||||||
|
// pills carve out `pointer-events-auto` to stay clickable.
|
||||||
|
function SnappingChips() {
|
||||||
|
const snappingMode = useEditor((s) => s.snappingMode)
|
||||||
|
const cycleSnappingMode = useEditor((s) => s.cycleSnappingMode)
|
||||||
|
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
||||||
|
const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
|
||||||
|
|
||||||
|
const gridActive = resolveSnapFlags(snappingMode).grid
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col gap-1.5 rounded-lg border border-border bg-background/95 px-3 py-2.5 shadow-lg backdrop-blur-md">
|
<>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
|
||||||
|
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
|
||||||
|
onClick={() => cycleSnappingMode()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-1 items-center gap-1.5 font-medium">
|
||||||
|
<Icon
|
||||||
|
className="shrink-0"
|
||||||
|
height={13}
|
||||||
|
icon={SNAPPING_MODE_ICONS[snappingMode]}
|
||||||
|
width={13}
|
||||||
|
/>
|
||||||
|
<span className="truncate">Snapping: {SNAPPING_MODE_LABELS[snappingMode]}</span>
|
||||||
|
</span>
|
||||||
|
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Shift" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Snapping mode — click or press Shift to cycle</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{gridActive ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-label={`Grid step: ${gridSnapStep.toFixed(2)} m`}
|
||||||
|
className={`${PILL_CLASS} pointer-events-auto cursor-pointer transition-colors hover:bg-accent`}
|
||||||
|
onClick={() => setGridSnapStep(nextGridSnapStep(gridSnapStep))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">
|
||||||
|
Grid: <span className="tabular-nums">{gridSnapStep.toFixed(2)}</span> m
|
||||||
|
</span>
|
||||||
|
<ShortcutToken className="h-6 px-1.5 text-[10px]" value="Ctrl" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Grid step — click or tap Ctrl to cycle</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextualHelperPanel({
|
||||||
|
hints,
|
||||||
|
showSnapping = false,
|
||||||
|
}: {
|
||||||
|
hints: ContextualShortcutHint[]
|
||||||
|
showSnapping?: boolean
|
||||||
|
}) {
|
||||||
|
if (hints.length === 0 && !showSnapping) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex max-w-[260px] -translate-y-1/2 flex-col items-end gap-2">
|
||||||
|
{showSnapping ? <SnappingChips /> : null}
|
||||||
{hints.map((hint) => (
|
{hints.map((hint) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid min-w-0 grid-cols-1 gap-1 rounded-md text-sm',
|
PILL_CLASS,
|
||||||
hint.active && '-mx-1 bg-primary/10 px-1.5 py-1 text-foreground',
|
'w-full justify-between',
|
||||||
|
hint.active && 'border-primary/40 bg-primary/10 text-foreground',
|
||||||
)}
|
)}
|
||||||
key={`${hint.keys.join('+')}:${hint.label}`}
|
key={`${hint.keys.join('+')}:${hint.label}`}
|
||||||
>
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium leading-snug">{hint.label}</span>
|
||||||
<ShortcutSequence keys={hint.keys} />
|
<ShortcutSequence keys={hint.keys} />
|
||||||
<span className="min-w-0 text-muted-foreground text-xs leading-snug">{hint.label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||||
import { resolveSelectModeHelpHints } from '../../../lib/contextual-help'
|
import {
|
||||||
|
ROTATE_HANDLE_DRAG_LABEL,
|
||||||
|
resolveRotateHandleHelpHints,
|
||||||
|
resolveSelectModeHelpHints,
|
||||||
|
} from '../../../lib/contextual-help'
|
||||||
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
|
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { BuildingHelper } from './building-helper'
|
import { BuildingHelper } from './building-helper'
|
||||||
@@ -62,6 +66,7 @@ export function HelperManager() {
|
|||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const tool = useEditor((s) => s.tool)
|
const tool = useEditor((s) => s.tool)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const modifiers = useActiveModifierKeys()
|
const modifiers = useActiveModifierKeys()
|
||||||
@@ -87,9 +92,16 @@ export function HelperManager() {
|
|||||||
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
||||||
if (isMobile) return null
|
if (isMobile) return null
|
||||||
|
|
||||||
|
// Rotating a node via its in-world gizmo: advertise Shift = free rotation,
|
||||||
|
// the same angle-step bypass wall drafting exposes. Takes priority over the
|
||||||
|
// idle select-mode hints since a handle drag is the active interaction.
|
||||||
|
if (activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL) {
|
||||||
|
return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
|
||||||
|
}
|
||||||
|
|
||||||
if (movingNode) {
|
if (movingNode) {
|
||||||
if (movingNode.type === 'building') return <BuildingHelper showRotate />
|
if (movingNode.type === 'building') return <BuildingHelper showRotate />
|
||||||
return <ItemHelper shiftPressed={modifiers.shift} showEsc />
|
return <ItemHelper showEsc />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'material-paint') {
|
if (mode === 'material-paint') {
|
||||||
|
|||||||
@@ -2,21 +2,18 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
|
|||||||
|
|
||||||
interface ItemHelperProps {
|
interface ItemHelperProps {
|
||||||
showEsc?: boolean
|
showEsc?: boolean
|
||||||
shiftPressed?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) {
|
export function ItemHelper({ showEsc }: ItemHelperProps) {
|
||||||
return (
|
return (
|
||||||
<ContextualHelperPanel
|
<ContextualHelperPanel
|
||||||
|
showSnapping
|
||||||
hints={[
|
hints={[
|
||||||
{ keys: ['Left click'], label: 'Place item' },
|
{ keys: ['Left click'], label: 'Place item' },
|
||||||
{ keys: ['R'], label: 'Rotate counterclockwise' },
|
{ keys: ['R'], label: 'Rotate counterclockwise' },
|
||||||
{ keys: ['T'], label: 'Rotate clockwise' },
|
{ keys: ['T'], label: 'Rotate clockwise' },
|
||||||
{
|
{ keys: ['Shift'], label: 'Cycle snapping mode' },
|
||||||
keys: ['Shift'],
|
{ keys: ['Alt'], label: 'Free place (no snap)' },
|
||||||
label: shiftPressed ? 'Guided constraints bypassed' : 'Free place',
|
|
||||||
active: shiftPressed,
|
|
||||||
},
|
|
||||||
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
|
{ keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ export function RegisteredToolHelper({
|
|||||||
if (hints.length === 0) return null
|
if (hints.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<ContextualHelperPanel
|
<ContextualHelperPanel
|
||||||
hints={hints.map((hint) => ({
|
showSnapping
|
||||||
keys: [hint.key],
|
hints={hints.map((hint) => {
|
||||||
label:
|
// Shift is a per-kind bypass for item / opening / zone / duct placement
|
||||||
shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label,
|
// ("Free place", "Free angle", …) — those hints flip to a bypassed
|
||||||
active: shiftPressed && hint.key === 'Shift',
|
// state while held. For wall / fence, Shift now cycles the snapping
|
||||||
}))}
|
// mode (no hold-to-bypass), so it must NOT show the bypass treatment.
|
||||||
|
const isBypassHint = hint.key === 'Shift' && hint.label !== 'Cycle snapping mode'
|
||||||
|
return {
|
||||||
|
keys: [hint.key],
|
||||||
|
label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label,
|
||||||
|
active: shiftPressed && isBypassHint,
|
||||||
|
}
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ContextualHelperPanel } from './contextual-helper-panel'
|
|||||||
export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) {
|
export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<ContextualHelperPanel
|
<ContextualHelperPanel
|
||||||
|
showSnapping
|
||||||
hints={[
|
hints={[
|
||||||
{ keys: ['Left click'], label: 'Set corner' },
|
{ keys: ['Left click'], label: 'Set corner' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import type { Mesh } from 'three'
|
import type { Mesh } from 'three'
|
||||||
|
import { resolveOverlayPolicy } from '../lib/interaction/overlay-policy'
|
||||||
import useEditor from '../store/use-editor'
|
import useEditor from '../store/use-editor'
|
||||||
|
import useInteractionScope from '../store/use-interaction-scope'
|
||||||
|
|
||||||
export const ViewerZoneSystem = () => {
|
export const ViewerZoneSystem = () => {
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const { levelId, zoneId } = useViewer.getState().selection
|
const { levelId, zoneId } = useViewer.getState().selection
|
||||||
const structureLayer = useEditor.getState().structureLayer
|
const structureLayer = useEditor.getState().structureLayer
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
|
// During any active interaction zone labels step back entirely (Sims-light).
|
||||||
|
const zoneLabelsHidden =
|
||||||
|
resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
|
||||||
|
|
||||||
sceneRegistry.byType.zone!.forEach((id) => {
|
sceneRegistry.byType.zone!.forEach((id) => {
|
||||||
const obj = sceneRegistry.nodes.get(id)
|
const obj = sceneRegistry.nodes.get(id)
|
||||||
@@ -35,7 +40,7 @@ export const ViewerZoneSystem = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Labels: always visible on the current level (regardless of mode or zone selection)
|
// Labels: always visible on the current level (regardless of mode or zone selection)
|
||||||
const showLabel = !!levelId && isOnSelectedLevel
|
const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
|
||||||
const targetOpacity = showLabel ? '1' : '0'
|
const targetOpacity = showLabel ? '1' : '0'
|
||||||
const labelEl = document.getElementById(`${id}-label`)
|
const labelEl = document.getElementById(`${id}-label`)
|
||||||
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
||||||
|
|||||||
@@ -40,12 +40,56 @@ export const useKeyboard = ({
|
|||||||
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
|
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shift cycles the snapping mode while a snapping-mode-governed draft is
|
||||||
|
// armed: wall / fence build, item placement (build + item tool), and any
|
||||||
|
// active node move (`movingNode` — covers item 3D moves plus the generic
|
||||||
|
// registry move for shelf / spawn / column / stair). For items, free place
|
||||||
|
// moved to Alt, so Shift is free to cycle here too. Elsewhere Shift keeps
|
||||||
|
// its existing meaning — multi-select in plain select mode (no movingNode),
|
||||||
|
// free-place bypass during opening / zone placement — so this predicate
|
||||||
|
// must NOT fire for those. Door / window moves still use Shift for free
|
||||||
|
// place (out of this overhaul's scope), so they're excluded.
|
||||||
|
const isSnappingCycleContext = () => {
|
||||||
|
const ed = useEditor.getState()
|
||||||
|
const moving = ed.movingNode
|
||||||
|
if (moving != null) return moving.type !== 'door' && moving.type !== 'window'
|
||||||
|
return (
|
||||||
|
ed.mode === 'build' && (ed.tool === 'wall' || ed.tool === 'fence' || ed.tool === 'item')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
|
||||||
|
// between) cycles the grid step — same context as the Shift snapping-mode
|
||||||
|
// cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone
|
||||||
|
// and is cleared the instant any other key fires, so chords like Ctrl+Z /
|
||||||
|
// Ctrl+C never cycle.
|
||||||
|
let ctrlTapClean = false
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Control' || e.key === 'Meta') {
|
||||||
|
// Only a fresh, modifier-free press starts a clean-tap candidate;
|
||||||
|
// ignore key-repeat and presses already part of a combo.
|
||||||
|
ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey
|
||||||
|
} else {
|
||||||
|
// Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks
|
||||||
|
// the clean tap.
|
||||||
|
ctrlTapClean = false
|
||||||
|
}
|
||||||
|
|
||||||
// Don't handle shortcuts if user is typing in an input
|
// Don't handle shortcuts if user is typing in an input
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) {
|
||||||
|
// Cycle the global snapping mode (grid → lines → angles → off).
|
||||||
|
// `'off'` is the snap bypass now, so Shift no longer holds-to-bypass.
|
||||||
|
e.preventDefault()
|
||||||
|
useEditor.getState().cycleSnappingMode()
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
_toolCancelConsumed = false
|
_toolCancelConsumed = false
|
||||||
@@ -91,6 +135,9 @@ export const useKeyboard = ({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setPhase('furnish')
|
useEditor.getState().setPhase('furnish')
|
||||||
useEditor.getState().setMode('build')
|
useEditor.getState().setMode('build')
|
||||||
|
// Set the item tool explicitly so the active tool never inherits a
|
||||||
|
// stale tool from a prior build session.
|
||||||
|
useEditor.getState().setTool('item')
|
||||||
useEditor.getState().setActiveSidebarPanel('items')
|
useEditor.getState().setActiveSidebarPanel('items')
|
||||||
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
||||||
if (isVersionPreviewMode) return
|
if (isVersionPreviewMode) return
|
||||||
@@ -98,6 +145,8 @@ export const useKeyboard = ({
|
|||||||
useEditor.getState().setPhase('structure')
|
useEditor.getState().setPhase('structure')
|
||||||
useEditor.getState().setStructureLayer('zones')
|
useEditor.getState().setStructureLayer('zones')
|
||||||
useEditor.getState().setMode('build')
|
useEditor.getState().setMode('build')
|
||||||
|
// Set the zone tool explicitly so it never inherits a stale tool.
|
||||||
|
useEditor.getState().setTool('zone')
|
||||||
}
|
}
|
||||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -109,6 +158,9 @@ export const useKeyboard = ({
|
|||||||
useEditor.getState().setPhase('structure')
|
useEditor.getState().setPhase('structure')
|
||||||
useEditor.getState().setStructureLayer('elements')
|
useEditor.getState().setStructureLayer('elements')
|
||||||
useEditor.getState().setMode('build')
|
useEditor.getState().setMode('build')
|
||||||
|
// Set the wall tool explicitly so B never inherits a stale tool
|
||||||
|
// (e.g. fence) left over from a prior build session.
|
||||||
|
useEditor.getState().setTool('wall')
|
||||||
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
|
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
|
||||||
if (isVersionPreviewMode) return
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -346,8 +398,28 @@ export const useKeyboard = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Control' && e.key !== 'Meta') return
|
||||||
|
const wasClean = ctrlTapClean
|
||||||
|
ctrlTapClean = false
|
||||||
|
if (!wasClean) return
|
||||||
|
// Same scope as the Shift snapping-mode cycle: wall / fence build only,
|
||||||
|
// and never while typing in an input.
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isSnappingCycleContext()) return
|
||||||
|
// Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05).
|
||||||
|
useEditor.getState().cycleGridSnapStep()
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
window.addEventListener('keyup', handleKeyUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
window.removeEventListener('keyup', handleKeyUp)
|
||||||
|
}
|
||||||
}, [disabled, isVersionPreviewMode])
|
}, [disabled, isVersionPreviewMode])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ export type {
|
|||||||
ViewMode,
|
ViewMode,
|
||||||
WorkspaceMode,
|
WorkspaceMode,
|
||||||
} from './store/use-editor'
|
} from './store/use-editor'
|
||||||
export { default as useEditor } from './store/use-editor'
|
export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from './store/use-editor'
|
||||||
export {
|
export {
|
||||||
default as useOpeningGuides,
|
default as useOpeningGuides,
|
||||||
type OpeningGuide3D,
|
type OpeningGuide3D,
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ describe('resolveSelectModeHelpHints', () => {
|
|||||||
keys: ['Cmd/Ctrl', 'Right click'],
|
keys: ['Cmd/Ctrl', 'Right click'],
|
||||||
label: 'Drag left or right to rotate selected object',
|
label: 'Drag left or right to rotate selected object',
|
||||||
})
|
})
|
||||||
expect(hints).toContainEqual({
|
// The Shift bypass hint is gated to the in-progress direct-move gesture
|
||||||
|
// (Cmd/Ctrl held); on an idle selection it must not appear (Shift there
|
||||||
|
// means multi-select, not bypass).
|
||||||
|
expect(hints).not.toContainEqual({
|
||||||
keys: ['Shift'],
|
keys: ['Shift'],
|
||||||
label: 'Hold to bypass snaps and angle steps',
|
label: 'Hold to bypass snaps and angle steps',
|
||||||
active: false,
|
active: false,
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ export type ContextualShortcutHint = {
|
|||||||
active?: boolean
|
active?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `activeHandleDrag.label` value a rotate gizmo sets while dragging, so the
|
||||||
|
// contextual HUD can surface the Shift = free-rotation toggle for the duration
|
||||||
|
// (mirrors how wall drafting advertises Shift). Distinct from resize handles,
|
||||||
|
// which route their own measurement label here.
|
||||||
|
export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle'
|
||||||
|
|
||||||
|
// Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step
|
||||||
|
// (free rotation), the same toggle wall drafting exposes. `active` lights the
|
||||||
|
// pill while Shift is held.
|
||||||
|
export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualShortcutHint[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
keys: [SHIFT_KEY],
|
||||||
|
label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely',
|
||||||
|
active: shiftPressed,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export type SelectModeHelpContext = {
|
export type SelectModeHelpContext = {
|
||||||
selectedCount: number
|
selectedCount: number
|
||||||
hasMovableSelection: boolean
|
hasMovableSelection: boolean
|
||||||
@@ -79,11 +98,16 @@ export function resolveSelectModeHelpHints({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hints.push({
|
// The Shift bypass only applies to an in-progress direct move/rotate
|
||||||
keys: [SHIFT_KEY],
|
// (the Cmd/Ctrl-drag gesture), so only surface it while that modifier is
|
||||||
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
|
// engaged — not on an idle selection, where Shift means multi-select.
|
||||||
active: shiftPressed,
|
if (commandPressed && (hasMovableSelection || hasRotatableSelection)) {
|
||||||
})
|
hints.push({
|
||||||
|
keys: [SHIFT_KEY],
|
||||||
|
label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps',
|
||||||
|
active: shiftPressed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!commandPressed) {
|
if (!commandPressed) {
|
||||||
hints.push({
|
hints.push({
|
||||||
|
|||||||
@@ -11,6 +11,46 @@ export function rotatePlanVector(x: number, y: number, rotation: number): [numbe
|
|||||||
return [x * cos + y * sin, -x * sin + y * cos]
|
return [x * cos + y * sin, -x * sin + y * cos]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Converts a world X/Z point into the floor-plan-local (building-local)
|
||||||
|
// frame used by the SVG scene `<g>` and every stored node position. The
|
||||||
|
// inverse of `floorplanLocalToWorldPoint`. Shared so the floor-plan panel
|
||||||
|
// and the 2D move overlay resolve the same frame — feeding a world-space
|
||||||
|
// `original` into a local-space cursor solver lands the drop off by the
|
||||||
|
// building's world X/Z (worse for an off-origin building).
|
||||||
|
export function worldToFloorplanLocalPoint(
|
||||||
|
worldX: number,
|
||||||
|
worldZ: number,
|
||||||
|
buildingPosition: readonly [number, number, number],
|
||||||
|
buildingRotationY: number,
|
||||||
|
): Point2D {
|
||||||
|
const dx = worldX - buildingPosition[0]
|
||||||
|
const dz = worldZ - buildingPosition[2]
|
||||||
|
const cos = Math.cos(buildingRotationY)
|
||||||
|
const sin = Math.sin(buildingRotationY)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: dx * cos - dz * sin,
|
||||||
|
y: dx * sin + dz * cos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse of `worldToFloorplanLocalPoint`: floor-plan-local X/Y → world X/Z.
|
||||||
|
export function floorplanLocalToWorldPoint(
|
||||||
|
point: Point2D | [number, number],
|
||||||
|
buildingPosition: readonly [number, number, number],
|
||||||
|
buildingRotationY: number,
|
||||||
|
): { x: number; z: number } {
|
||||||
|
const localX = Array.isArray(point) ? point[0] : point.x
|
||||||
|
const localY = Array.isArray(point) ? point[1] : point.y
|
||||||
|
const cos = Math.cos(buildingRotationY)
|
||||||
|
const sin = Math.sin(buildingRotationY)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: buildingPosition[0] + localX * cos + localY * sin,
|
||||||
|
z: buildingPosition[2] - localX * sin + localY * cos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getRotatedRectanglePolygon(
|
export function getRotatedRectanglePolygon(
|
||||||
center: Point2D,
|
center: Point2D,
|
||||||
width: number,
|
width: number,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
clampPlanValue,
|
clampPlanValue,
|
||||||
doesPolygonIntersectSelectionBounds,
|
doesPolygonIntersectSelectionBounds,
|
||||||
|
floorplanLocalToWorldPoint,
|
||||||
getDistanceToWallSegment,
|
getDistanceToWallSegment,
|
||||||
getFloorplanSelectionBounds,
|
getFloorplanSelectionBounds,
|
||||||
getPlanPointDistance,
|
getPlanPointDistance,
|
||||||
@@ -20,6 +21,7 @@ export {
|
|||||||
movePlanPointTowards,
|
movePlanPointTowards,
|
||||||
pointMatchesWallPlanPoint,
|
pointMatchesWallPlanPoint,
|
||||||
rotatePlanVector,
|
rotatePlanVector,
|
||||||
|
worldToFloorplanLocalPoint,
|
||||||
} from './geometry'
|
} from './geometry'
|
||||||
export {
|
export {
|
||||||
buildFloorplanItemEntry,
|
buildFloorplanItemEntry,
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
type AttachClass,
|
||||||
|
attachClassOf,
|
||||||
|
type HotSetCandidate,
|
||||||
|
isCandidateInHotSet,
|
||||||
|
isPickableForAttach,
|
||||||
|
} from './hot-set'
|
||||||
|
|
||||||
|
const floor: HotSetCandidate = {
|
||||||
|
type: 'level',
|
||||||
|
isFloorLike: true,
|
||||||
|
exposesTop: false,
|
||||||
|
attachClass: 'surface',
|
||||||
|
}
|
||||||
|
const wall: HotSetCandidate = {
|
||||||
|
type: 'wall',
|
||||||
|
isFloorLike: false,
|
||||||
|
exposesTop: false,
|
||||||
|
attachClass: 'surface',
|
||||||
|
}
|
||||||
|
const ceiling: HotSetCandidate = {
|
||||||
|
type: 'ceiling',
|
||||||
|
isFloorLike: false,
|
||||||
|
exposesTop: false,
|
||||||
|
attachClass: 'surface',
|
||||||
|
}
|
||||||
|
const table: HotSetCandidate = {
|
||||||
|
type: 'item',
|
||||||
|
isFloorLike: false,
|
||||||
|
exposesTop: true,
|
||||||
|
attachClass: 'surface',
|
||||||
|
}
|
||||||
|
const wallShelf: HotSetCandidate = {
|
||||||
|
type: 'shelf',
|
||||||
|
isFloorLike: false,
|
||||||
|
exposesTop: true,
|
||||||
|
attachClass: 'wall',
|
||||||
|
}
|
||||||
|
const ceilingFan: HotSetCandidate = {
|
||||||
|
type: 'item',
|
||||||
|
isFloorLike: false,
|
||||||
|
exposesTop: true,
|
||||||
|
attachClass: 'ceiling',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('attachClassOf', () => {
|
||||||
|
test('wall and wall-side collapse to wall', () => {
|
||||||
|
expect(attachClassOf('wall')).toBe('wall')
|
||||||
|
expect(attachClassOf('wall-side')).toBe('wall')
|
||||||
|
})
|
||||||
|
test('ceiling maps to ceiling', () => {
|
||||||
|
expect(attachClassOf('ceiling')).toBe('ceiling')
|
||||||
|
})
|
||||||
|
test('undefined/null/unknown is surface-resting', () => {
|
||||||
|
expect(attachClassOf(undefined)).toBe('surface')
|
||||||
|
expect(attachClassOf(null)).toBe('surface')
|
||||||
|
expect(attachClassOf('')).toBe('surface')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isPickableForAttach — wall-mounted (window)', () => {
|
||||||
|
test('only walls are eligible; floor/ceiling/tops are not', () => {
|
||||||
|
expect(isPickableForAttach('wall', wall)).toBe(true)
|
||||||
|
expect(isPickableForAttach('wall', floor)).toBe(false)
|
||||||
|
expect(isPickableForAttach('wall', ceiling)).toBe(false)
|
||||||
|
expect(isPickableForAttach('wall', table)).toBe(false)
|
||||||
|
expect(isPickableForAttach('wall', wallShelf)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isPickableForAttach — ceiling-mounted', () => {
|
||||||
|
test('only ceilings are eligible', () => {
|
||||||
|
expect(isPickableForAttach('ceiling', ceiling)).toBe(true)
|
||||||
|
expect(isPickableForAttach('ceiling', wall)).toBe(false)
|
||||||
|
expect(isPickableForAttach('ceiling', floor)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isPickableForAttach — surface-resting (sofa / cactus)', () => {
|
||||||
|
test('floor is always eligible', () => {
|
||||||
|
expect(isPickableForAttach('surface', floor)).toBe(true)
|
||||||
|
})
|
||||||
|
test('host tops (table, wall-shelf top) are eligible', () => {
|
||||||
|
expect(isPickableForAttach('surface', table)).toBe(true)
|
||||||
|
expect(isPickableForAttach('surface', wallShelf)).toBe(true)
|
||||||
|
})
|
||||||
|
test('a wall (no top surface) is not eligible', () => {
|
||||||
|
expect(isPickableForAttach('surface', wall)).toBe(false)
|
||||||
|
})
|
||||||
|
test('a ceiling-mounted host (ceiling fan) is never eligible — Track E', () => {
|
||||||
|
expect(isPickableForAttach('surface', ceilingFan)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isCandidateInHotSet — by scope', () => {
|
||||||
|
const surfaceClass: AttachClass = 'surface'
|
||||||
|
test('idle: everything is in the hot-set (selection filtering lives elsewhere)', () => {
|
||||||
|
expect(isCandidateInHotSet({ kind: 'idle' }, null, ceilingFan)).toBe(true)
|
||||||
|
})
|
||||||
|
test('placing a surface item: derives from attach class', () => {
|
||||||
|
const scope = {
|
||||||
|
kind: 'placing' as const,
|
||||||
|
nodeId: 'i1',
|
||||||
|
nodeType: 'item',
|
||||||
|
view: '3d' as const,
|
||||||
|
pressDrag: false,
|
||||||
|
}
|
||||||
|
expect(isCandidateInHotSet(scope, surfaceClass, floor)).toBe(true)
|
||||||
|
expect(isCandidateInHotSet(scope, surfaceClass, ceilingFan)).toBe(false)
|
||||||
|
})
|
||||||
|
test('moving a wall-mounted item: only walls', () => {
|
||||||
|
const scope = {
|
||||||
|
kind: 'moving' as const,
|
||||||
|
nodeId: 'w1',
|
||||||
|
nodeType: 'window',
|
||||||
|
view: '2d' as const,
|
||||||
|
}
|
||||||
|
expect(isCandidateInHotSet(scope, 'wall', wall)).toBe(true)
|
||||||
|
expect(isCandidateInHotSet(scope, 'wall', table)).toBe(false)
|
||||||
|
})
|
||||||
|
test('non-placement active scopes target nothing in the scene', () => {
|
||||||
|
expect(isCandidateInHotSet({ kind: 'box-select' }, null, floor)).toBe(false)
|
||||||
|
expect(
|
||||||
|
isCandidateInHotSet({ kind: 'handle-drag', nodeId: 'x', handle: 'h' }, null, floor),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// The hot-set: which scene objects are raycast-eligible during an interaction.
|
||||||
|
//
|
||||||
|
// It is never hand-authored per interaction. It falls out of the node's
|
||||||
|
// `asset.attachTo` plus whether a candidate exposes a top surface. "Floor item"
|
||||||
|
// really means surface-resting: it rests on the floor *or* any host's top
|
||||||
|
// surface. Walls and ceilings are the special attach modes. Adding a node kind
|
||||||
|
// = set `attachTo` (or leave blank); the hot-set follows with zero per-kind
|
||||||
|
// wiring.
|
||||||
|
|
||||||
|
import type { InteractionScope } from './scope'
|
||||||
|
|
||||||
|
// What a node attaches to, collapsed to the three classes the hot-set cares
|
||||||
|
// about. `wall-side` is a wall attachment; everything without an explicit
|
||||||
|
// `attachTo` is surface-resting.
|
||||||
|
export type AttachClass = 'wall' | 'ceiling' | 'surface'
|
||||||
|
|
||||||
|
export function attachClassOf(attachTo: string | undefined | null): AttachClass {
|
||||||
|
if (attachTo === 'wall' || attachTo === 'wall-side') return 'wall'
|
||||||
|
if (attachTo === 'ceiling') return 'ceiling'
|
||||||
|
return 'surface'
|
||||||
|
}
|
||||||
|
|
||||||
|
// The metadata the hot-set needs about a candidate host/surface. Derived from
|
||||||
|
// the candidate node + its registry definition by the caller, so this module
|
||||||
|
// stays pure and unit-testable without the scene or registry.
|
||||||
|
export type HotSetCandidate = {
|
||||||
|
type: string
|
||||||
|
// The level floor plane / ground a surface-resting node can always rest on.
|
||||||
|
isFloorLike: boolean
|
||||||
|
// The candidate exposes a usable top surface (registry
|
||||||
|
// `capabilities.surfaces.top`) — a table, a shelf, a slab.
|
||||||
|
exposesTop: boolean
|
||||||
|
// The candidate's own attach class. A ceiling fan is `ceiling`: it hangs from
|
||||||
|
// the ceiling and must never act as a host top (Track E).
|
||||||
|
attachClass: AttachClass
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a node whose attach class is `placed`, is `candidate` a valid
|
||||||
|
// host/surface to pick during placement or move?
|
||||||
|
export function isPickableForAttach(placed: AttachClass, candidate: HotSetCandidate): boolean {
|
||||||
|
if (placed === 'wall') return candidate.type === 'wall'
|
||||||
|
if (placed === 'ceiling') return candidate.type === 'ceiling'
|
||||||
|
// Surface-resting: the floor, or any host that exposes a top surface — but
|
||||||
|
// never a ceiling-mounted host (a floor lamp must not land on a ceiling fan).
|
||||||
|
if (candidate.isFloorLike) return true
|
||||||
|
if (!candidate.exposesTop) return false
|
||||||
|
if (candidate.attachClass === 'ceiling') return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hot-set predicate for a whole scope. For placing/moving it derives from
|
||||||
|
// the moving node's attach class; for every other active scope nothing in the
|
||||||
|
// scene is a placement target, so the body's own raycast owns the pointer.
|
||||||
|
// `idle` returns true here — selection/phase filtering stays in the selection
|
||||||
|
// manager; this only narrows what an *active* interaction can target.
|
||||||
|
export function isCandidateInHotSet(
|
||||||
|
scope: InteractionScope,
|
||||||
|
placedAttachClass: AttachClass | null,
|
||||||
|
candidate: HotSetCandidate,
|
||||||
|
): boolean {
|
||||||
|
if (scope.kind === 'idle') return true
|
||||||
|
if (scope.kind === 'placing' || scope.kind === 'moving') {
|
||||||
|
if (placedAttachClass === null) return true
|
||||||
|
return isPickableForAttach(placedAttachClass, candidate)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { resolveOverlayPolicy } from './overlay-policy'
|
||||||
|
import type { ActiveInteractionScope } from './scope'
|
||||||
|
|
||||||
|
const ACTIVE_SCOPES: ActiveInteractionScope[] = [
|
||||||
|
{ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: false },
|
||||||
|
{ kind: 'moving', nodeId: 'i1', nodeType: 'item', view: '2d' },
|
||||||
|
{ kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
|
||||||
|
{ kind: 'drafting', tool: 'wall' },
|
||||||
|
{ kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 },
|
||||||
|
{ kind: 'box-select' },
|
||||||
|
{ kind: 'painting' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('resolveOverlayPolicy', () => {
|
||||||
|
test('idle keeps everything shown and pickable', () => {
|
||||||
|
const p = resolveOverlayPolicy({ kind: 'idle' })
|
||||||
|
expect(p.zoneLabels).toBe('shown')
|
||||||
|
expect(p.contextBadges).toBe('shown')
|
||||||
|
expect(p.conflictingControls).toBe('shown')
|
||||||
|
expect(p.sceneObjectsPickable).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every active scope hides zone labels, fades badges, hides conflicting controls', () => {
|
||||||
|
for (const scope of ACTIVE_SCOPES) {
|
||||||
|
const p = resolveOverlayPolicy(scope)
|
||||||
|
expect(p.zoneLabels).toBe('hidden')
|
||||||
|
expect(p.contextBadges).toBe('faded')
|
||||||
|
expect(p.conflictingControls).toBe('hidden')
|
||||||
|
expect(p.sceneObjectsPickable).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('active affordances and the contextual HUD always stay interactive', () => {
|
||||||
|
for (const scope of [{ kind: 'idle' } as const, ...ACTIVE_SCOPES]) {
|
||||||
|
const p = resolveOverlayPolicy(scope)
|
||||||
|
expect(p.activeAffordances).toBe('shown')
|
||||||
|
expect(p.contextualHudInteractive).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// The overlay scope matrix — the "Sims-light" feel. During any non-idle
|
||||||
|
// interaction, two layers behave differently:
|
||||||
|
//
|
||||||
|
// - 3D scene objects stay VISIBLE but become NON-pickable (the hot-set owns
|
||||||
|
// what the active interaction can target). Context is preserved; you just
|
||||||
|
// can't grab the wrong thing.
|
||||||
|
// - DOM/HUD overlays step back, differentiated by how distracting they are:
|
||||||
|
// zone labels -> hidden (not a primary editing concern)
|
||||||
|
// context badges -> faded + pointer-events:none (hover name pills)
|
||||||
|
// other controls -> hard-hidden (other objects' handles, the floating
|
||||||
|
// action menu, conflicting controls)
|
||||||
|
//
|
||||||
|
// The active interaction's own affordances (ghost, snap guides, dimension
|
||||||
|
// labels, the active handle) always stay — "default-off, opt-in for the active
|
||||||
|
// action". The contextual control HUD is exempt from the pointer-events
|
||||||
|
// step-back because it *is* the active interaction's own controls.
|
||||||
|
|
||||||
|
import { type InteractionScope, isActive } from './scope'
|
||||||
|
|
||||||
|
export type OverlayVisibility = 'shown' | 'faded' | 'hidden'
|
||||||
|
|
||||||
|
export type OverlayPolicy = {
|
||||||
|
zoneLabels: OverlayVisibility
|
||||||
|
// Hover name pills / context badges.
|
||||||
|
contextBadges: OverlayVisibility
|
||||||
|
// Other objects' handles + the floating action menu — anything whose action
|
||||||
|
// would conflict with the active interaction.
|
||||||
|
conflictingControls: OverlayVisibility
|
||||||
|
// Non-active scene objects: visible always, pickable only when idle.
|
||||||
|
sceneObjectsPickable: boolean
|
||||||
|
// The active interaction's own ghost/guides/dimension labels/handle. Always
|
||||||
|
// shown; this field exists so consumers can assert the contract.
|
||||||
|
activeAffordances: 'shown'
|
||||||
|
// The contextual control HUD keeps pointer events even while everything else
|
||||||
|
// steps back, because it is the active interaction's own controls.
|
||||||
|
contextualHudInteractive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE_POLICY: OverlayPolicy = {
|
||||||
|
zoneLabels: 'shown',
|
||||||
|
contextBadges: 'shown',
|
||||||
|
conflictingControls: 'shown',
|
||||||
|
sceneObjectsPickable: true,
|
||||||
|
activeAffordances: 'shown',
|
||||||
|
contextualHudInteractive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVE_POLICY: OverlayPolicy = {
|
||||||
|
zoneLabels: 'hidden',
|
||||||
|
contextBadges: 'faded',
|
||||||
|
conflictingControls: 'hidden',
|
||||||
|
sceneObjectsPickable: false,
|
||||||
|
activeAffordances: 'shown',
|
||||||
|
contextualHudInteractive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOverlayPolicy(scope: InteractionScope): OverlayPolicy {
|
||||||
|
return isActive(scope) ? ACTIVE_POLICY : IDLE_POLICY
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// The authoritative description of "what the user is currently doing".
|
||||||
|
//
|
||||||
|
// Before this, that question was answered by re-deriving from 7+ independent
|
||||||
|
// `useEditor` flags (`mode`, `tool`, `movingNode`, `placementDragMode`,
|
||||||
|
// `activeHandleDrag`, `curvingWall`, `curvingFence`, `editingHole`,
|
||||||
|
// `movingWallEndpoint`, `movingFenceEndpoint`, …). Every overlay and pick site
|
||||||
|
// re-derived its behaviour from a different subset, so the flags could drift
|
||||||
|
// into illegal combinations (moving + curving at once; a stale `movingNode`
|
||||||
|
// after a drag ended). Collapsing them into one discriminated union makes those
|
||||||
|
// combinations unrepresentable: a scope is exactly one interaction at a time,
|
||||||
|
// and `idle` carries no interaction payload at all.
|
||||||
|
|
||||||
|
export type InteractionView = '2d' | '3d'
|
||||||
|
|
||||||
|
// Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
|
||||||
|
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
|
||||||
|
// (rather than four sibling scopes) keeps the union small while still making
|
||||||
|
// "curving and hole-editing at once" unrepresentable.
|
||||||
|
export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary'
|
||||||
|
|
||||||
|
export type InteractionScope =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
// Placing a fresh node (catalog/preset/build tool). `pressDrag` is the
|
||||||
|
// gizmo press-drag flavour (commit on release) vs click-to-place.
|
||||||
|
| {
|
||||||
|
kind: 'placing'
|
||||||
|
nodeId: string
|
||||||
|
nodeType: string
|
||||||
|
view: InteractionView
|
||||||
|
pressDrag: boolean
|
||||||
|
}
|
||||||
|
// Moving an existing node.
|
||||||
|
| { kind: 'moving'; nodeId: string; nodeType: string; view: InteractionView }
|
||||||
|
// Dragging a resize/translate/rotate handle of a selected node.
|
||||||
|
| { kind: 'handle-drag'; nodeId: string; handle: string }
|
||||||
|
// Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
|
||||||
|
| { kind: 'drafting'; tool: string }
|
||||||
|
// Reshaping a selected node's geometry (see ReshapeKind).
|
||||||
|
| { kind: 'reshaping'; nodeId: string; reshape: ReshapeKind; holeIndex?: number }
|
||||||
|
// Marquee selection drag.
|
||||||
|
| { kind: 'box-select' }
|
||||||
|
// Material paint application.
|
||||||
|
| { kind: 'painting' }
|
||||||
|
|
||||||
|
export type InteractionKind = InteractionScope['kind']
|
||||||
|
|
||||||
|
export type ActiveInteractionScope = Exclude<InteractionScope, { kind: 'idle' }>
|
||||||
|
|
||||||
|
export const IDLE_SCOPE: InteractionScope = { kind: 'idle' }
|
||||||
|
|
||||||
|
export function isIdle(scope: InteractionScope): scope is { kind: 'idle' } {
|
||||||
|
return scope.kind === 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActive(scope: InteractionScope): scope is ActiveInteractionScope {
|
||||||
|
return scope.kind !== 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
// The node a scope is acting on, if any. Drafting/box-select/painting/idle
|
||||||
|
// target no single existing node.
|
||||||
|
export function scopeNodeId(scope: InteractionScope): string | null {
|
||||||
|
switch (scope.kind) {
|
||||||
|
case 'placing':
|
||||||
|
case 'moving':
|
||||||
|
case 'handle-drag':
|
||||||
|
case 'reshaping':
|
||||||
|
return scope.nodeId
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection/hover picking is only meaningful while idle. During any active
|
||||||
|
// interaction the pointer belongs to that interaction's body, not to selecting
|
||||||
|
// a different object — the picking choke point should not route a hover/click
|
||||||
|
// to selection while this is false.
|
||||||
|
export function selectionEnabled(scope: InteractionScope): boolean {
|
||||||
|
return scope.kind === 'idle'
|
||||||
|
}
|
||||||
@@ -40,4 +40,61 @@ describe('resolvePlanarCursorPosition', () => {
|
|||||||
expect(moved.point).toEqual([11, 19])
|
expect(moved.point).toEqual([11, 19])
|
||||||
expect(moved.anchor).toEqual([4.1, 6.1])
|
expect(moved.anchor).toEqual([4.1, 6.1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Track B regression: "off-slab cursor, on-slab footprint stays at center".
|
||||||
|
// When the gizmo is grabbed off the footprint center (e.g. near a slab edge),
|
||||||
|
// the resolved center must track original + cursorDelta and be independent of
|
||||||
|
// the initial grab offset — so a footprint fully inside a slab cannot be
|
||||||
|
// pushed off the edge just because the cursor sample landed off-center.
|
||||||
|
test('relative mode cancels the off-center gizmo grab offset so the committed center is offset-independent', () => {
|
||||||
|
const original: [number, number] = [2, 2]
|
||||||
|
const firstSample: [number, number] = [2.3, 2.3]
|
||||||
|
const cursor: [number, number] = [3.1, 1.6]
|
||||||
|
|
||||||
|
const start = resolvePlanarCursorPosition({
|
||||||
|
cursor: firstSample,
|
||||||
|
original,
|
||||||
|
anchor: null,
|
||||||
|
mode: 'relative',
|
||||||
|
})
|
||||||
|
|
||||||
|
// First sample absorbs the off-center grab: the footprint stays put.
|
||||||
|
expect(start.point).toEqual(original)
|
||||||
|
expect(start.anchor).toEqual(firstSample)
|
||||||
|
|
||||||
|
const moved = resolvePlanarCursorPosition({
|
||||||
|
cursor,
|
||||||
|
original,
|
||||||
|
anchor: start.anchor,
|
||||||
|
mode: 'relative',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Committed center = original + (cursor - firstSample), i.e. the gizmo
|
||||||
|
// offset is cancelled regardless of where on the footprint it was grabbed.
|
||||||
|
const expected: [number, number] = [
|
||||||
|
original[0] + (cursor[0] - firstSample[0]),
|
||||||
|
original[1] + (cursor[1] - firstSample[1]),
|
||||||
|
]
|
||||||
|
expect(moved.point[0]).toBeCloseTo(expected[0])
|
||||||
|
expect(moved.point[1]).toBeCloseTo(expected[1])
|
||||||
|
|
||||||
|
// The result must not depend on the absolute grab offset: grabbing the same
|
||||||
|
// footprint dead-center and moving by the same delta yields the same center.
|
||||||
|
const centerStart = resolvePlanarCursorPosition({
|
||||||
|
cursor: original,
|
||||||
|
original,
|
||||||
|
anchor: null,
|
||||||
|
mode: 'relative',
|
||||||
|
})
|
||||||
|
const delta: [number, number] = [cursor[0] - firstSample[0], cursor[1] - firstSample[1]]
|
||||||
|
const centerMoved = resolvePlanarCursorPosition({
|
||||||
|
cursor: [original[0] + delta[0], original[1] + delta[1]],
|
||||||
|
original,
|
||||||
|
anchor: centerStart.anchor,
|
||||||
|
mode: 'relative',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(centerMoved.point[0]).toBeCloseTo(moved.point[0])
|
||||||
|
expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import {
|
||||||
|
DEFAULT_SNAPPING_MODE,
|
||||||
|
nextSnappingMode,
|
||||||
|
resolveSnapFlags,
|
||||||
|
SNAPPING_MODES,
|
||||||
|
} from './snapping-mode'
|
||||||
|
|
||||||
|
describe('resolveSnapFlags', () => {
|
||||||
|
it('default mode is grid', () => {
|
||||||
|
expect(DEFAULT_SNAPPING_MODE).toBe('grid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("default 'grid' reproduces today's full snapping (grid + magnetic + angles on)", () => {
|
||||||
|
expect(resolveSnapFlags('grid')).toEqual({ grid: true, magnetic: true, angles: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("'off' disables grid, magnetic, and angles", () => {
|
||||||
|
expect(resolveSnapFlags('off')).toEqual({ grid: false, magnetic: false, angles: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("'lines' keeps magnetic but drops the grid lattice and angle lock", () => {
|
||||||
|
expect(resolveSnapFlags('lines')).toEqual({ grid: false, magnetic: true, angles: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("'angles' keeps the angle lock but drops grid and magnetic", () => {
|
||||||
|
expect(resolveSnapFlags('angles')).toEqual({ grid: false, magnetic: false, angles: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("'lines' and 'angles' are distinct", () => {
|
||||||
|
expect(resolveSnapFlags('lines')).not.toEqual(resolveSnapFlags('angles'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cycles through every mode and wraps', () => {
|
||||||
|
const seen = [DEFAULT_SNAPPING_MODE]
|
||||||
|
let mode = DEFAULT_SNAPPING_MODE
|
||||||
|
for (let i = 0; i < SNAPPING_MODES.length - 1; i += 1) {
|
||||||
|
mode = nextSnappingMode(mode)
|
||||||
|
seen.push(mode)
|
||||||
|
}
|
||||||
|
expect(seen).toEqual(SNAPPING_MODES)
|
||||||
|
expect(nextSnappingMode(mode)).toBe(DEFAULT_SNAPPING_MODE)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Snapping mode is a single global, user-cyclable control that maps onto the
|
||||||
|
* two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`).
|
||||||
|
* The default `'grid'` resolves to the exact pair the editor shipped with
|
||||||
|
* before this control existed (grid on, magnetic on), so the default path is
|
||||||
|
* behaviourally unchanged — only when a user opts into `'lines'` or `'off'`
|
||||||
|
* does any snap math get suppressed.
|
||||||
|
*/
|
||||||
|
export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off'
|
||||||
|
|
||||||
|
export const SNAPPING_MODES: SnappingMode[] = ['grid', 'lines', 'angles', 'off']
|
||||||
|
|
||||||
|
export const DEFAULT_SNAPPING_MODE: SnappingMode = 'grid'
|
||||||
|
|
||||||
|
export type SnapFlags = {
|
||||||
|
grid: boolean
|
||||||
|
magnetic: boolean
|
||||||
|
angles: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure mapping from the curated mode enum onto the individual snap knobs.
|
||||||
|
*
|
||||||
|
* - `grid` → grid + magnetic + angles (today's default; full snapping).
|
||||||
|
* - `lines` → magnetic only (alignment / wall beacons, no grid lattice, no
|
||||||
|
* angle lock).
|
||||||
|
* - `angles` → angle lock only (15° wall/line rays, no grid lattice, no
|
||||||
|
* magnetic beacons).
|
||||||
|
* - `off` → nothing snaps.
|
||||||
|
*/
|
||||||
|
export function resolveSnapFlags(mode: SnappingMode): SnapFlags {
|
||||||
|
switch (mode) {
|
||||||
|
case 'grid':
|
||||||
|
return { grid: true, magnetic: true, angles: true }
|
||||||
|
case 'lines':
|
||||||
|
return { grid: false, magnetic: true, angles: false }
|
||||||
|
case 'angles':
|
||||||
|
return { grid: false, magnetic: false, angles: true }
|
||||||
|
case 'off':
|
||||||
|
return { grid: false, magnetic: false, angles: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAPPING_MODE_LABELS: Record<SnappingMode, string> = {
|
||||||
|
grid: 'Grid',
|
||||||
|
lines: 'Lines',
|
||||||
|
angles: 'Angles',
|
||||||
|
off: 'Off',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSnappingModeLabel(mode: SnappingMode): string {
|
||||||
|
return SNAPPING_MODE_LABELS[mode]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextSnappingMode(mode: SnappingMode): SnappingMode {
|
||||||
|
const index = SNAPPING_MODES.indexOf(mode)
|
||||||
|
return SNAPPING_MODES[(index + 1) % SNAPPING_MODES.length] ?? DEFAULT_SNAPPING_MODE
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type WallSnapRadii,
|
type WallSnapRadii,
|
||||||
} from '../components/tools/wall/wall-drafting'
|
} from '../components/tools/wall/wall-drafting'
|
||||||
import useAlignmentGuides from '../store/use-alignment-guides'
|
import useAlignmentGuides from '../store/use-alignment-guides'
|
||||||
import useEditor from '../store/use-editor'
|
import { isMagneticSnapActive } from '../store/use-editor'
|
||||||
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
|
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
|
||||||
|
|
||||||
const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
|
const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
|
||||||
@@ -181,7 +181,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
|
|||||||
const nodes = input.nodes ?? useScene.getState().nodes
|
const nodes = input.nodes ?? useScene.getState().nodes
|
||||||
const walls = getLevelWalls(nodes, input.levelId, input.walls)
|
const walls = getLevelWalls(nodes, input.levelId, input.walls)
|
||||||
const fallbackPoint = input.fallbackPoint
|
const fallbackPoint = input.fallbackPoint
|
||||||
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap
|
const magnetic = input.magnetic ?? isMagneticSnapActive()
|
||||||
|
|
||||||
const wallSnap = snapWallDraftPointDetailed({
|
const wallSnap = snapWallDraftPointDetailed({
|
||||||
point: input.rawPoint,
|
point: input.rawPoint,
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ import {
|
|||||||
resolvePaintTargetFromSelection,
|
resolvePaintTargetFromSelection,
|
||||||
type SingleSurfaceMaterialRole,
|
type SingleSurfaceMaterialRole,
|
||||||
} from '../lib/material-paint'
|
} from '../lib/material-paint'
|
||||||
|
import {
|
||||||
|
DEFAULT_SNAPPING_MODE,
|
||||||
|
nextSnappingMode,
|
||||||
|
resolveSnapFlags,
|
||||||
|
SNAPPING_MODES,
|
||||||
|
type SnappingMode,
|
||||||
|
} from '../lib/snapping-mode'
|
||||||
|
import useInteractionScope from './use-interaction-scope'
|
||||||
|
|
||||||
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
|
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai'
|
||||||
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
||||||
@@ -374,11 +382,20 @@ type EditorState = {
|
|||||||
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
||||||
gridSnapStep: GridSnapStep
|
gridSnapStep: GridSnapStep
|
||||||
setGridSnapStep: (step: GridSnapStep) => void
|
setGridSnapStep: (step: GridSnapStep) => void
|
||||||
|
// Cycles the grid step through GRID_SNAP_STEPS (0.5 → 0.25 → 0.1 → 0.05 →
|
||||||
|
// 0.5) and returns the new value. Bound to the measurement-step shortcut.
|
||||||
|
cycleGridSnapStep: () => GridSnapStep
|
||||||
// Magnetic snapping while drafting — snaps wall endpoints onto existing
|
// Magnetic snapping while drafting — snaps wall endpoints onto existing
|
||||||
// wall corners / wall bodies (the "magnetic" beacon). Independent of grid
|
// wall corners / wall bodies (the "magnetic" beacon). Independent of grid
|
||||||
// snap. On by default; toggled from the Display menu.
|
// snap. On by default; toggled from the Display menu.
|
||||||
magneticSnap: boolean
|
magneticSnap: boolean
|
||||||
setMagneticSnap: (enabled: boolean) => void
|
setMagneticSnap: (enabled: boolean) => void
|
||||||
|
// Global, user-cyclable snapping mode. Maps onto `gridSnapStep` (grid) and
|
||||||
|
// `magneticSnap` via `resolveSnapFlags`. Default `'grid'` reproduces the
|
||||||
|
// historical behaviour (grid + magnetic on).
|
||||||
|
snappingMode: SnappingMode
|
||||||
|
setSnappingMode: (mode: SnappingMode) => void
|
||||||
|
cycleSnappingMode: () => SnappingMode
|
||||||
showReferenceFloor: boolean
|
showReferenceFloor: boolean
|
||||||
toggleReferenceFloor: () => void
|
toggleReferenceFloor: () => void
|
||||||
setShowReferenceFloor: (show: boolean) => void
|
setShowReferenceFloor: (show: boolean) => void
|
||||||
@@ -427,6 +444,7 @@ type PersistedEditorLayoutState = Pick<
|
|||||||
| 'floorplanSelectionTool'
|
| 'floorplanSelectionTool'
|
||||||
| 'gridSnapStep'
|
| 'gridSnapStep'
|
||||||
| 'magneticSnap'
|
| 'magneticSnap'
|
||||||
|
| 'snappingMode'
|
||||||
| 'showReferenceFloor'
|
| 'showReferenceFloor'
|
||||||
| 'referenceFloorOffset'
|
| 'referenceFloorOffset'
|
||||||
| 'referenceFloorOpacity'
|
| 'referenceFloorOpacity'
|
||||||
@@ -450,6 +468,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
|
|||||||
floorplanSelectionTool: 'click',
|
floorplanSelectionTool: 'click',
|
||||||
gridSnapStep: 0.5,
|
gridSnapStep: 0.5,
|
||||||
magneticSnap: true,
|
magneticSnap: true,
|
||||||
|
snappingMode: DEFAULT_SNAPPING_MODE,
|
||||||
showReferenceFloor: false,
|
showReferenceFloor: false,
|
||||||
referenceFloorOffset: 1,
|
referenceFloorOffset: 1,
|
||||||
referenceFloorOpacity: 0.35,
|
referenceFloorOpacity: 0.35,
|
||||||
@@ -568,6 +587,9 @@ function normalizePersistedEditorLayoutState(
|
|||||||
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||||
// Default on: only an explicit persisted `false` disables it.
|
// Default on: only an explicit persisted `false` disables it.
|
||||||
magneticSnap: state?.magneticSnap !== false,
|
magneticSnap: state?.magneticSnap !== false,
|
||||||
|
snappingMode: SNAPPING_MODES.includes(state?.snappingMode as SnappingMode)
|
||||||
|
? (state?.snappingMode as SnappingMode)
|
||||||
|
: DEFAULT_SNAPPING_MODE,
|
||||||
showReferenceFloor: state?.showReferenceFloor === true,
|
showReferenceFloor: state?.showReferenceFloor === true,
|
||||||
referenceFloorOffset:
|
referenceFloorOffset:
|
||||||
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
|
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
|
||||||
@@ -760,6 +782,10 @@ const useEditor = create<EditorState>()(
|
|||||||
else if (tool) {
|
else if (tool) {
|
||||||
set({ tool: null })
|
set({ tool: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scope = useInteractionScope.getState()
|
||||||
|
if (mode === 'material-paint') scope.begin({ kind: 'painting' })
|
||||||
|
else scope.endIf((s) => s.kind === 'painting')
|
||||||
},
|
},
|
||||||
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
|
tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool,
|
||||||
setTool: (tool) => set({ tool }),
|
setTool: (tool) => set({ tool }),
|
||||||
@@ -814,25 +840,68 @@ const useEditor = create<EditorState>()(
|
|||||||
| null,
|
| null,
|
||||||
placementDragMode: false,
|
placementDragMode: false,
|
||||||
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
|
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
|
||||||
setMovingNode: (node) =>
|
setMovingNode: (node) => {
|
||||||
set(
|
const scope = useInteractionScope.getState()
|
||||||
node === null
|
if (node === null) {
|
||||||
? // Preserve `movingNodeOrigin` across the clear so the
|
scope.endIf((s) => s.kind === 'placing' || s.kind === 'moving')
|
||||||
// non-owning side's effect cleanup — which fires after
|
// Preserve `movingNodeOrigin` across the clear so the non-owning
|
||||||
// `setMovingNode(null)` propagates — can still read who
|
// side's effect cleanup — which fires after `setMovingNode(null)`
|
||||||
// finalised. The next non-null `setMovingNode` resets it.
|
// propagates — can still read who finalised. The next non-null
|
||||||
// Always clear the press-drag flag when a move ends.
|
// `setMovingNode` resets it. Always clear the press-drag flag.
|
||||||
{ movingNode: null, placementDragMode: false }
|
set({ movingNode: null, placementDragMode: false })
|
||||||
: { movingNode: node, movingNodeOrigin: null },
|
return
|
||||||
),
|
}
|
||||||
|
const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew)
|
||||||
|
if (isNew) {
|
||||||
|
scope.begin({
|
||||||
|
kind: 'placing',
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeType: node.type,
|
||||||
|
view: '3d',
|
||||||
|
pressDrag: get().placementDragMode,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
scope.begin({ kind: 'moving', nodeId: node.id, nodeType: node.type, view: '3d' })
|
||||||
|
}
|
||||||
|
set({ movingNode: node, movingNodeOrigin: null })
|
||||||
|
},
|
||||||
movingNodeOrigin: null as '2d' | '3d' | null,
|
movingNodeOrigin: null as '2d' | '3d' | null,
|
||||||
setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
|
setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
|
||||||
movingWallEndpoint: null,
|
movingWallEndpoint: null,
|
||||||
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
setMovingWallEndpoint: (value) => {
|
||||||
|
const scope = useInteractionScope.getState()
|
||||||
|
if (value) scope.begin({ kind: 'reshaping', nodeId: value.wall.id, reshape: 'endpoint' })
|
||||||
|
else {
|
||||||
|
const prev = get().movingWallEndpoint
|
||||||
|
if (prev)
|
||||||
|
scope.endIf(
|
||||||
|
(s) =>
|
||||||
|
s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.wall.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set({ movingWallEndpoint: value })
|
||||||
|
},
|
||||||
movingFenceEndpoint: null,
|
movingFenceEndpoint: null,
|
||||||
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
setMovingFenceEndpoint: (value) => {
|
||||||
|
const scope = useInteractionScope.getState()
|
||||||
|
if (value) scope.begin({ kind: 'reshaping', nodeId: value.fence.id, reshape: 'endpoint' })
|
||||||
|
else {
|
||||||
|
const prev = get().movingFenceEndpoint
|
||||||
|
if (prev)
|
||||||
|
scope.endIf(
|
||||||
|
(s) =>
|
||||||
|
s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.fence.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set({ movingFenceEndpoint: value })
|
||||||
|
},
|
||||||
activeHandleDrag: null,
|
activeHandleDrag: null,
|
||||||
setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
|
setActiveHandleDrag: (drag) => {
|
||||||
|
const scope = useInteractionScope.getState()
|
||||||
|
if (drag) scope.begin({ kind: 'handle-drag', nodeId: drag.nodeId, handle: drag.label })
|
||||||
|
else scope.endIf((s) => s.kind === 'handle-drag')
|
||||||
|
set({ activeHandleDrag: drag })
|
||||||
|
},
|
||||||
rotationAxis: 'y',
|
rotationAxis: 'y',
|
||||||
cycleRotationAxis: () => {
|
cycleRotationAxis: () => {
|
||||||
const order = ['y', 'x', 'z'] as const
|
const order = ['y', 'x', 'z'] as const
|
||||||
@@ -841,9 +910,31 @@ const useEditor = create<EditorState>()(
|
|||||||
return next
|
return next
|
||||||
},
|
},
|
||||||
curvingWall: null,
|
curvingWall: null,
|
||||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
setCurvingWall: (wall) => {
|
||||||
|
const scope = useInteractionScope.getState()
|
||||||
|
if (wall) scope.begin({ kind: 'reshaping', nodeId: wall.id, reshape: 'curve' })
|
||||||
|
else {
|
||||||
|
const prev = get().curvingWall
|
||||||
|
if (prev)
|
||||||
|
scope.endIf(
|
||||||
|
(s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set({ curvingWall: wall })
|
||||||
|
},
|
||||||
curvingFence: null,
|
curvingFence: null,
|
||||||
setCurvingFence: (fence) => set({ curvingFence: fence }),
|
setCurvingFence: (fence) => {
|
||||||
|
const scope = useInteractionScope.getState()
|
||||||
|
if (fence) scope.begin({ kind: 'reshaping', nodeId: fence.id, reshape: 'curve' })
|
||||||
|
else {
|
||||||
|
const prev = get().curvingFence
|
||||||
|
if (prev)
|
||||||
|
scope.endIf(
|
||||||
|
(s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set({ curvingFence: fence })
|
||||||
|
},
|
||||||
selectedMaterialTarget: null,
|
selectedMaterialTarget: null,
|
||||||
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
||||||
activePaintMaterial: null,
|
activePaintMaterial: null,
|
||||||
@@ -925,7 +1016,24 @@ const useEditor = create<EditorState>()(
|
|||||||
spaces: {},
|
spaces: {},
|
||||||
setSpaces: (spaces) => set({ spaces }),
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
editingHole: null,
|
editingHole: null,
|
||||||
setEditingHole: (hole) => set({ editingHole: hole }),
|
setEditingHole: (hole) => {
|
||||||
|
const scope = useInteractionScope.getState()
|
||||||
|
if (hole)
|
||||||
|
scope.begin({
|
||||||
|
kind: 'reshaping',
|
||||||
|
nodeId: hole.nodeId,
|
||||||
|
reshape: 'hole',
|
||||||
|
holeIndex: hole.holeIndex,
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
const prev = get().editingHole
|
||||||
|
if (prev)
|
||||||
|
scope.endIf(
|
||||||
|
(s) => s.kind === 'reshaping' && s.reshape === 'hole' && s.nodeId === prev.nodeId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set({ editingHole: hole })
|
||||||
|
},
|
||||||
hoveredHole: null,
|
hoveredHole: null,
|
||||||
setHoveredHole: (hole) =>
|
setHoveredHole: (hole) =>
|
||||||
set((state) =>
|
set((state) =>
|
||||||
@@ -1007,8 +1115,22 @@ const useEditor = create<EditorState>()(
|
|||||||
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
||||||
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||||
setGridSnapStep: (step) => set({ gridSnapStep: step }),
|
setGridSnapStep: (step) => set({ gridSnapStep: step }),
|
||||||
|
cycleGridSnapStep: () => {
|
||||||
|
const current = get().gridSnapStep
|
||||||
|
const index = GRID_SNAP_STEPS.indexOf(current)
|
||||||
|
const next = GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]!
|
||||||
|
set({ gridSnapStep: next })
|
||||||
|
return next
|
||||||
|
},
|
||||||
magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
|
magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap,
|
||||||
setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
|
setMagneticSnap: (enabled) => set({ magneticSnap: enabled }),
|
||||||
|
snappingMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingMode,
|
||||||
|
setSnappingMode: (mode) => set({ snappingMode: mode }),
|
||||||
|
cycleSnappingMode: () => {
|
||||||
|
const next = nextSnappingMode(get().snappingMode)
|
||||||
|
set({ snappingMode: next })
|
||||||
|
return next
|
||||||
|
},
|
||||||
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
|
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
|
||||||
toggleReferenceFloor: () =>
|
toggleReferenceFloor: () =>
|
||||||
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
|
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
|
||||||
@@ -1101,6 +1223,7 @@ const useEditor = create<EditorState>()(
|
|||||||
floorplanSelectionTool: state.floorplanSelectionTool,
|
floorplanSelectionTool: state.floorplanSelectionTool,
|
||||||
gridSnapStep: state.gridSnapStep,
|
gridSnapStep: state.gridSnapStep,
|
||||||
magneticSnap: state.magneticSnap,
|
magneticSnap: state.magneticSnap,
|
||||||
|
snappingMode: state.snappingMode,
|
||||||
showReferenceFloor: state.showReferenceFloor,
|
showReferenceFloor: state.showReferenceFloor,
|
||||||
referenceFloorOffset: state.referenceFloorOffset,
|
referenceFloorOffset: state.referenceFloorOffset,
|
||||||
referenceFloorOpacity: state.referenceFloorOpacity,
|
referenceFloorOpacity: state.referenceFloorOpacity,
|
||||||
@@ -1109,4 +1232,28 @@ const useEditor = create<EditorState>()(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective magnetic-snap state: the legacy `magneticSnap` flag AND the
|
||||||
|
* snapping mode's magnetic component. Default mode `'grid'` resolves magnetic
|
||||||
|
* to `true`, so with the default-on `magneticSnap` this returns `true` exactly
|
||||||
|
* as before; only `'off'` (or an explicitly-disabled `magneticSnap`) turns it
|
||||||
|
* off. Read from the smallest magnetic choke points so the mode is honoured
|
||||||
|
* without retuning any snap math.
|
||||||
|
*/
|
||||||
|
export function isMagneticSnapActive(): boolean {
|
||||||
|
const state = useEditor.getState()
|
||||||
|
return state.magneticSnap && resolveSnapFlags(state.snappingMode).magnetic
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective angle-lock state: the snapping mode's angle component. Default mode
|
||||||
|
* `'grid'` resolves angles to `true`, so the 15° draft lock behaves exactly as
|
||||||
|
* before; `'lines'` and `'off'` suppress it. Read from the smallest angle-lock
|
||||||
|
* choke points (wall / fence draft call sites) so the mode is honoured without
|
||||||
|
* retuning any snap math.
|
||||||
|
*/
|
||||||
|
export function isAngleSnapActive(): boolean {
|
||||||
|
return resolveSnapFlags(useEditor.getState().snappingMode).angles
|
||||||
|
}
|
||||||
|
|
||||||
export default useEditor
|
export default useEditor
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { isActive, isIdle, scopeNodeId, selectionEnabled } from '../lib/interaction/scope'
|
||||||
|
import useInteractionScope from './use-interaction-scope'
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
useInteractionScope.getState().end()
|
||||||
|
}
|
||||||
|
afterEach(reset)
|
||||||
|
|
||||||
|
describe('use-interaction-scope state machine', () => {
|
||||||
|
test('starts idle', () => {
|
||||||
|
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
||||||
|
expect(isIdle(useInteractionScope.getState().scope)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('begin enters an interaction; end returns to idle atomically', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
s.begin({ kind: 'moving', nodeId: 'item_1', nodeType: 'item', view: '3d' })
|
||||||
|
expect(useInteractionScope.getState().scope).toEqual({
|
||||||
|
kind: 'moving',
|
||||||
|
nodeId: 'item_1',
|
||||||
|
nodeType: 'item',
|
||||||
|
view: '3d',
|
||||||
|
})
|
||||||
|
s.end()
|
||||||
|
// No interaction payload leaks past end — the scope is plain idle, so a
|
||||||
|
// stale nodeId/handle is unrepresentable.
|
||||||
|
expect(useInteractionScope.getState().scope).toEqual({ kind: 'idle' })
|
||||||
|
expect(scopeNodeId(useInteractionScope.getState().scope)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('begin is single-owner: a new interaction replaces the prior one', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
s.begin({ kind: 'drafting', tool: 'wall' })
|
||||||
|
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
||||||
|
const scope = useInteractionScope.getState().scope
|
||||||
|
expect(scope.kind).toBe('handle-drag')
|
||||||
|
// The prior drafting payload is gone — illegal "drafting + handle-drag"
|
||||||
|
// combination is unrepresentable.
|
||||||
|
expect(scopeNodeId(scope)).toBe('wall_1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('update patches the live payload of the active scope', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
s.begin({ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: false })
|
||||||
|
s.update({ pressDrag: true })
|
||||||
|
const scope = useInteractionScope.getState().scope
|
||||||
|
expect(scope.kind === 'placing' && scope.pressDrag).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('update is a no-op when idle', () => {
|
||||||
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.update({ kind: 'moving', nodeId: 'x', nodeType: 'item', view: '3d' })
|
||||||
|
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('update cannot change which interaction is running', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
s.begin({ kind: 'moving', nodeId: 'i1', nodeType: 'item', view: '3d' })
|
||||||
|
s.update({ kind: 'placing', nodeId: 'i1', nodeType: 'item', view: '3d', pressDrag: true })
|
||||||
|
expect(useInteractionScope.getState().scope.kind).toBe('moving')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selectionEnabled only while idle', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(true)
|
||||||
|
s.begin({ kind: 'box-select' })
|
||||||
|
expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(false)
|
||||||
|
expect(isActive(useInteractionScope.getState().scope)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('end is idempotent', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
s.end()
|
||||||
|
s.end()
|
||||||
|
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import {
|
||||||
|
type ActiveInteractionScope,
|
||||||
|
IDLE_SCOPE,
|
||||||
|
type InteractionScope,
|
||||||
|
} from '../lib/interaction/scope'
|
||||||
|
|
||||||
|
// The authoritative interaction state machine. A single owner holds exactly one
|
||||||
|
// scope at a time. `begin` enters an interaction (atomically replacing any prior
|
||||||
|
// one — a single owner, no producer races), `update` narrows the live payload,
|
||||||
|
// and `end` returns to idle atomically so no interaction payload can leak past
|
||||||
|
// the end of its interaction. There is no setter that can leave the store in an
|
||||||
|
// illegal half-state: the only writable shape is `InteractionScope`.
|
||||||
|
|
||||||
|
export type InteractionScopeState = {
|
||||||
|
scope: InteractionScope
|
||||||
|
// Enter an interaction. If one is already active it is ended first, so the
|
||||||
|
// store is always single-owner.
|
||||||
|
begin: (scope: ActiveInteractionScope) => void
|
||||||
|
// Patch the current scope's payload. Ignored when idle, or when the patch's
|
||||||
|
// implied kind differs from the active kind — payload updates must not change
|
||||||
|
// which interaction is running (use `begin` for that).
|
||||||
|
update: (patch: Partial<ActiveInteractionScope>) => void
|
||||||
|
// Return to idle atomically. Both commit and cancel paths call this; the
|
||||||
|
// distinction (write vs revert) lives in the interaction body, not here.
|
||||||
|
end: () => void
|
||||||
|
// Return to idle only if the active scope matches `match`. Used when scope is
|
||||||
|
// driven from independent legacy flag clears, so clearing one flag (e.g. a
|
||||||
|
// fence curve) cannot stomp an unrelated active scope (e.g. a wall move).
|
||||||
|
endIf: (match: (scope: ActiveInteractionScope) => boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const useInteractionScope = create<InteractionScopeState>((set, get) => ({
|
||||||
|
scope: IDLE_SCOPE,
|
||||||
|
begin: (scope) => set({ scope }),
|
||||||
|
update: (patch) =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.scope.kind === 'idle') return state
|
||||||
|
if ('kind' in patch && patch.kind !== state.scope.kind) return state
|
||||||
|
return { scope: { ...state.scope, ...patch } as InteractionScope }
|
||||||
|
}),
|
||||||
|
end: () => {
|
||||||
|
if (get().scope.kind === 'idle') return
|
||||||
|
set({ scope: IDLE_SCOPE })
|
||||||
|
},
|
||||||
|
endIf: (match) => {
|
||||||
|
const scope = get().scope
|
||||||
|
if (scope.kind === 'idle') return
|
||||||
|
if (match(scope)) set({ scope: IDLE_SCOPE })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default useInteractionScope
|
||||||
@@ -223,6 +223,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
|||||||
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
|
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
|
||||||
// direction + perpendicular for the cutout footprint.
|
// direction + perpendicular for the cutout footprint.
|
||||||
floorplan: buildDoorFloorplan,
|
floorplan: buildDoorFloorplan,
|
||||||
|
floorplanDependsOnSiblings: true,
|
||||||
// Stage D — placement (`def.tool`) + move-on-wall (`def.
|
// Stage D — placement (`def.tool`) + move-on-wall (`def.
|
||||||
// affordanceTools.move`). Both ports of the legacy tools at
|
// affordanceTools.move`). Both ports of the legacy tools at
|
||||||
// `editor/components/tools/door/`, relocated into the kind folder and
|
// `editor/components/tools/door/`, relocated into the kind folder and
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
type FencePlanPoint,
|
type FencePlanPoint,
|
||||||
|
isMagneticSnapActive,
|
||||||
isSegmentLongEnough,
|
isSegmentLongEnough,
|
||||||
snapFenceDraftPoint,
|
snapFenceDraftPoint,
|
||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
@@ -171,6 +172,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
|
|||||||
fences: ctx.levelFences,
|
fences: ctx.levelFences,
|
||||||
ignoreFenceIds: [ctx.fenceId as string],
|
ignoreFenceIds: [ctx.fenceId as string],
|
||||||
bypassSnap: modifiers.shift,
|
bypassSnap: modifiers.shift,
|
||||||
|
magnetic: !modifiers.shift && isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Figma-style alignment: nudge the dragged endpoint onto another wall /
|
// Figma-style alignment: nudge the dragged endpoint onto another wall /
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
|||||||
|
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Set fence start / end' },
|
{ key: 'Left click', label: 'Set fence start / end' },
|
||||||
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
|
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
alignFloorplanDraftPoint,
|
alignFloorplanDraftPoint,
|
||||||
type FencePlanPoint,
|
type FencePlanPoint,
|
||||||
getSegmentGridStep,
|
getSegmentGridStep,
|
||||||
|
isMagneticSnapActive,
|
||||||
isSegmentLongEnough,
|
isSegmentLongEnough,
|
||||||
snapBuildingLocalToWorldGrid,
|
snapBuildingLocalToWorldGrid,
|
||||||
snapFenceDraftPoint,
|
snapFenceDraftPoint,
|
||||||
@@ -165,7 +166,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
|||||||
fences: nextFences,
|
fences: nextFences,
|
||||||
ignoreFenceIds: [node.id],
|
ignoreFenceIds: [node.id],
|
||||||
bypassSnap: modifiers.shiftKey,
|
bypassSnap: modifiers.shiftKey,
|
||||||
magnetic: !modifiers.shiftKey,
|
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
|
||||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
|
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
|
||||||
})
|
})
|
||||||
// Figma-style alignment on the dragged endpoint — snaps it onto
|
// Figma-style alignment on the dragged endpoint — snaps it onto
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
consumePlacementDragRelease,
|
consumePlacementDragRelease,
|
||||||
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
snapFenceDraftPoint,
|
snapFenceDraftPoint,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
@@ -201,6 +202,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
fences: levelFences,
|
fences: levelFences,
|
||||||
ignoreFenceIds: [fenceId],
|
ignoreFenceIds: [fenceId],
|
||||||
bypassSnap,
|
bypassSnap,
|
||||||
|
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
getAngleArcToSegmentReference,
|
getAngleArcToSegmentReference,
|
||||||
getAngleToSegmentReference,
|
getAngleToSegmentReference,
|
||||||
getSegmentAngleReferenceAtPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
isAngleSnapActive,
|
||||||
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
type SegmentAngleReference,
|
type SegmentAngleReference,
|
||||||
snapFenceDraftPoint,
|
snapFenceDraftPoint,
|
||||||
@@ -445,7 +447,6 @@ export const FenceTool: React.FC = () => {
|
|||||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const buildingState = useRef(0)
|
const buildingState = useRef(0)
|
||||||
const shiftPressed = useRef(false)
|
|
||||||
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||||
const measurementColor = isDark ? '#ffffff' : '#111111'
|
const measurementColor = isDark ? '#ffffff' : '#111111'
|
||||||
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
|
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
|
||||||
@@ -466,10 +467,13 @@ export const FenceTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Align the drafted point onto another object's nearest real anchor and
|
// Align the drafted point onto another object's nearest real anchor and
|
||||||
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
|
// publish the guide. Alt bypasses alignment. Returns the possibly snapped
|
||||||
// snapping. Returns the possibly snapped point.
|
// point.
|
||||||
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
||||||
if (bypass || alignmentCandidates.length === 0) {
|
// Figma alignment pulls the endpoint onto existing corners / edges, so it
|
||||||
|
// is a line snap — suppress it whenever magnetic snap is off (`'off'` /
|
||||||
|
// `'angles'`), matching the fence-geometry snap.
|
||||||
|
if (bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
return point
|
return point
|
||||||
}
|
}
|
||||||
@@ -494,13 +498,13 @@ export const FenceTool: React.FC = () => {
|
|||||||
if (!(cursorRef.current && previewRef.current)) return
|
if (!(cursorRef.current && previewRef.current)) return
|
||||||
const { walls, fences } = getCurrentLevelElements()
|
const { walls, fences } = getCurrentLevelElements()
|
||||||
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
// While drafting, the segment locks to 15° rays from its start
|
// While drafting, the segment locks to 15° rays from its start.
|
||||||
// unless Shift is held. Shift also bypasses grid and magnetic snap.
|
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
// there is no Shift hold-to-bypass. Alt still bypasses alignment guides.
|
||||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
const bypassAlign = event.nativeEvent?.altKey === true
|
||||||
|
|
||||||
if (buildingState.current === 1) {
|
if (buildingState.current === 1) {
|
||||||
const angleLocked = !bypassSnap
|
const angleLocked = isAngleSnapActive()
|
||||||
const snappedLocal = alignPoint(
|
const snappedLocal = alignPoint(
|
||||||
snapFenceDraftPoint({
|
snapFenceDraftPoint({
|
||||||
point: localPoint,
|
point: localPoint,
|
||||||
@@ -508,7 +512,7 @@ export const FenceTool: React.FC = () => {
|
|||||||
fences,
|
fences,
|
||||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||||
angleSnap: angleLocked,
|
angleSnap: angleLocked,
|
||||||
bypassSnap,
|
magnetic: isMagneticSnapActive(),
|
||||||
}),
|
}),
|
||||||
bypassAlign || angleLocked,
|
bypassAlign || angleLocked,
|
||||||
)
|
)
|
||||||
@@ -516,7 +520,6 @@ export const FenceTool: React.FC = () => {
|
|||||||
cursorRef.current.position.copy(endingPoint.current)
|
cursorRef.current.position.copy(endingPoint.current)
|
||||||
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
|
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
|
||||||
if (
|
if (
|
||||||
!bypassSnap &&
|
|
||||||
previousFenceEnd &&
|
previousFenceEnd &&
|
||||||
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
|
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
|
||||||
) {
|
) {
|
||||||
@@ -543,7 +546,12 @@ export const FenceTool: React.FC = () => {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const snappedPoint = alignPoint(
|
const snappedPoint = alignPoint(
|
||||||
snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }),
|
snapFenceDraftPoint({
|
||||||
|
point: localPoint,
|
||||||
|
walls,
|
||||||
|
fences,
|
||||||
|
magnetic: isMagneticSnapActive(),
|
||||||
|
}),
|
||||||
bypassAlign,
|
bypassAlign,
|
||||||
)
|
)
|
||||||
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
||||||
@@ -559,12 +567,16 @@ export const FenceTool: React.FC = () => {
|
|||||||
|
|
||||||
const { walls, fences } = getCurrentLevelElements()
|
const { walls, fences } = getCurrentLevelElements()
|
||||||
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
const bypassAlign = event.nativeEvent?.altKey === true
|
||||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
|
||||||
|
|
||||||
if (buildingState.current === 0) {
|
if (buildingState.current === 0) {
|
||||||
const snappedStart = alignPoint(
|
const snappedStart = alignPoint(
|
||||||
snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }),
|
snapFenceDraftPoint({
|
||||||
|
point: localClick,
|
||||||
|
walls,
|
||||||
|
fences,
|
||||||
|
magnetic: isMagneticSnapActive(),
|
||||||
|
}),
|
||||||
bypassAlign,
|
bypassAlign,
|
||||||
)
|
)
|
||||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||||
@@ -574,7 +586,7 @@ export const FenceTool: React.FC = () => {
|
|||||||
previewRef.current.visible = true
|
previewRef.current.visible = true
|
||||||
setDraftMeasurement(null)
|
setDraftMeasurement(null)
|
||||||
} else {
|
} else {
|
||||||
const angleLocked = !bypassSnap
|
const angleLocked = isAngleSnapActive()
|
||||||
const snappedEnd = alignPoint(
|
const snappedEnd = alignPoint(
|
||||||
snapFenceDraftPoint({
|
snapFenceDraftPoint({
|
||||||
point: localClick,
|
point: localClick,
|
||||||
@@ -582,7 +594,7 @@ export const FenceTool: React.FC = () => {
|
|||||||
fences,
|
fences,
|
||||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||||
angleSnap: angleLocked,
|
angleSnap: angleLocked,
|
||||||
bypassSnap,
|
magnetic: isMagneticSnapActive(),
|
||||||
}),
|
}),
|
||||||
bypassAlign || angleLocked,
|
bypassAlign || angleLocked,
|
||||||
)
|
)
|
||||||
@@ -614,20 +626,6 @@ export const FenceTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Shift') shiftPressed.current = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Shift') shiftPressed.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
|
|
||||||
// angle lock isn't stuck off when focus returns.
|
|
||||||
const onBlur = () => {
|
|
||||||
shiftPressed.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
if (buildingState.current === 1) {
|
if (buildingState.current === 1) {
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
@@ -638,17 +636,11 @@ export const FenceTool: React.FC = () => {
|
|||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
window.addEventListener('keyup', onKeyUp)
|
|
||||||
window.addEventListener('blur', onBlur)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
|
||||||
window.removeEventListener('keyup', onKeyUp)
|
|
||||||
window.removeEventListener('blur', onBlur)
|
|
||||||
useSegmentDraftChain.getState().clear('fence')
|
useSegmentDraftChain.getState().clear('fence')
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export const gutterDefinition: NodeDefinition<typeof GutterNode> = {
|
|||||||
parametrics: gutterParametrics,
|
parametrics: gutterParametrics,
|
||||||
handles: gutterHandles,
|
handles: gutterHandles,
|
||||||
floorplan: buildGutterFloorplan,
|
floorplan: buildGutterFloorplan,
|
||||||
|
floorplanDependsOnSiblings: true,
|
||||||
|
|
||||||
renderer: {
|
renderer: {
|
||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
|||||||
{ key: 'Left click', label: 'Place item' },
|
{ key: 'Left click', label: 'Place item' },
|
||||||
{ key: 'R', label: 'Rotate counterclockwise' },
|
{ key: 'R', label: 'Rotate counterclockwise' },
|
||||||
{ key: 'T', label: 'Rotate clockwise' },
|
{ key: 'T', label: 'Rotate clockwise' },
|
||||||
{ key: 'Shift', label: 'Free place' },
|
{ key: 'Shift', label: 'Cycle snapping mode' },
|
||||||
|
{ key: 'Alt', label: 'Free place (no snap)' },
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isSelected = ctx.viewState?.selected ?? false
|
const isSelected = ctx.viewState?.selected ?? false
|
||||||
|
const isMoving = ctx.viewState?.moving ?? false
|
||||||
const floorPlanUrl = node.asset.floorPlanUrl
|
const floorPlanUrl = node.asset.floorPlanUrl
|
||||||
const children: FloorplanGeometry[] = [
|
const children: FloorplanGeometry[] = [
|
||||||
{
|
{
|
||||||
@@ -214,8 +215,10 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
|
|||||||
rotation: transform.rotation,
|
rotation: transform.rotation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Move handle — orange dot at the item center. Only when selected.
|
// Move handle — orange dot at the item center. Only when selected and not
|
||||||
if (isSelected) {
|
// already moving: during a move the dot sits under the cursor, so a release
|
||||||
|
// over it would re-arm the move (and re-enter edit) instead of committing.
|
||||||
|
if (isSelected && !isMoving) {
|
||||||
children.push({
|
children.push({
|
||||||
kind: 'move-handle',
|
kind: 'move-handle',
|
||||||
point: [cx, cy],
|
point: [cx, cy],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
consumePlacementDragRelease,
|
consumePlacementDragRelease,
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
getFloorStackPreviewPosition,
|
getFloorStackPreviewPosition,
|
||||||
|
isMagneticSnapActive,
|
||||||
resolvePlanarCursorPosition,
|
resolvePlanarCursorPosition,
|
||||||
snapFenceDraftPoint,
|
snapFenceDraftPoint,
|
||||||
stripPlacementMetadataFlags,
|
stripPlacementMetadataFlags,
|
||||||
@@ -290,11 +291,13 @@ export const MoveRoofTool: React.FC<{
|
|||||||
|
|
||||||
const y = event.position[1]
|
const y = event.position[1]
|
||||||
|
|
||||||
|
const roofBypassSnap = event.nativeEvent?.shiftKey === true
|
||||||
const snappedLocal = snapFenceDraftPoint({
|
const snappedLocal = snapFenceDraftPoint({
|
||||||
point: [event.localPosition[0], event.localPosition[2]],
|
point: [event.localPosition[0], event.localPosition[2]],
|
||||||
walls: levelWalls,
|
walls: levelWalls,
|
||||||
fences: levelFences,
|
fences: levelFences,
|
||||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
bypassSnap: roofBypassSnap,
|
||||||
|
magnetic: !roofBypassSnap && isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
|
const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y)
|
||||||
const [rawLocalX, rawLocalZ] = computeLocal(
|
const [rawLocalX, rawLocalZ] = computeLocal(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
CursorSphere,
|
CursorSphere,
|
||||||
consumePlacementDragRelease,
|
consumePlacementDragRelease,
|
||||||
getSegmentGridStep,
|
getSegmentGridStep,
|
||||||
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
resolveAlignmentForActiveBuilding,
|
resolveAlignmentForActiveBuilding,
|
||||||
snapBuildingLocalToWorldGrid,
|
snapBuildingLocalToWorldGrid,
|
||||||
@@ -174,6 +175,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
|||||||
walls: levelWalls,
|
walls: levelWalls,
|
||||||
fences: levelFences,
|
fences: levelFences,
|
||||||
bypassSnap,
|
bypassSnap,
|
||||||
|
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
|
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { NodeDefinition } from '@pascal-app/core'
|
import type { NodeDefinition } from '@pascal-app/core'
|
||||||
import { buildWallFloorplan } from './floorplan'
|
import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
|
||||||
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
|
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
|
||||||
import { wallFloorplanMoveTarget } from './floorplan-move'
|
import { wallFloorplanMoveTarget } from './floorplan-move'
|
||||||
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
|
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
|
||||||
@@ -18,7 +18,8 @@ import { wallSlots } from './slots'
|
|||||||
* `renderer` + `system` keep wrap-exporting legacy WallRenderer +
|
* `renderer` + `system` keep wrap-exporting legacy WallRenderer +
|
||||||
* WallSystem + WallCutout.
|
* WallSystem + WallCutout.
|
||||||
* Stage C: `def.floorplan` builder produces the mitered plan footprint
|
* Stage C: `def.floorplan` builder produces the mitered plan footprint
|
||||||
* polygon using `ctx.siblings` to assemble miter context.
|
* polygon from shared floor-plan level data, with `ctx.siblings` as the
|
||||||
|
* direct-caller fallback.
|
||||||
* floorplan-panel.tsx's `wallPolygons` short-circuits to [] when
|
* floorplan-panel.tsx's `wallPolygons` short-circuits to [] when
|
||||||
* wall is registered.
|
* wall is registered.
|
||||||
*/
|
*/
|
||||||
@@ -98,9 +99,11 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
|||||||
// Priority 4 mirrors the legacy WallSystem's useFrame priority.
|
// Priority 4 mirrors the legacy WallSystem's useFrame priority.
|
||||||
priority: 4,
|
priority: 4,
|
||||||
},
|
},
|
||||||
// Stage C: floor-plan rendering. ctx.siblings provides other walls in
|
// Stage C: floor-plan rendering. Precomputes the level miter graph once
|
||||||
// the level so `calculateLevelMiters` can compute correct corner joins.
|
// per render pass, then the builder reads its own junctions by wall id.
|
||||||
|
computeFloorplanLevelData: computeWallFloorplanLevelData,
|
||||||
floorplan: buildWallFloorplan,
|
floorplan: buildWallFloorplan,
|
||||||
|
floorplanDependsOnSiblings: true,
|
||||||
// 2D drag affordances triggered by `endpoint-handle` primitives in
|
// 2D drag affordances triggered by `endpoint-handle` primitives in
|
||||||
// `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the
|
// `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the
|
||||||
// same legacy `MoveWallEndpointTool` flow, reachable from both the
|
// same legacy `MoveWallEndpointTool` flow, reachable from both the
|
||||||
@@ -114,7 +117,6 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
|||||||
|
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Left click', label: 'Set wall start / end' },
|
{ key: 'Left click', label: 'Set wall start / end' },
|
||||||
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
|
|
||||||
{ key: 'Esc', label: 'Cancel' },
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
alignFloorplanDraftPoint,
|
alignFloorplanDraftPoint,
|
||||||
getSegmentGridStep,
|
getSegmentGridStep,
|
||||||
|
isMagneticSnapActive,
|
||||||
isSegmentLongEnough,
|
isSegmentLongEnough,
|
||||||
snapBuildingLocalToWorldGrid,
|
snapBuildingLocalToWorldGrid,
|
||||||
snapScalarToGrid,
|
snapScalarToGrid,
|
||||||
@@ -193,7 +194,7 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
|
|||||||
walls,
|
walls,
|
||||||
ignoreWallIds: [node.id],
|
ignoreWallIds: [node.id],
|
||||||
bypassSnap: modifiers.shiftKey,
|
bypassSnap: modifiers.shiftKey,
|
||||||
magnetic: !modifiers.shiftKey,
|
magnetic: !modifiers.shiftKey && isMagneticSnapActive(),
|
||||||
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
|
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
|
||||||
})
|
})
|
||||||
// Figma-style alignment on the dragged corner — snaps it onto another
|
// Figma-style alignment on the dragged corner — snaps it onto another
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getWallMidpointHandlePoint,
|
getWallMidpointHandlePoint,
|
||||||
getWallPlanFootprint,
|
getWallPlanFootprint,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
|
type WallMiterData,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
@@ -35,6 +36,15 @@ function formatLengthMetric(meters: number): string {
|
|||||||
return `${Number.parseFloat(meters.toFixed(2))}m`
|
return `${Number.parseFloat(meters.toFixed(2))}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function computeWallFloorplanLevelData({
|
||||||
|
siblings,
|
||||||
|
}: {
|
||||||
|
siblings: ReadonlyArray<WallNode>
|
||||||
|
nodes: Record<string, AnyNode>
|
||||||
|
}): WallMiterData {
|
||||||
|
return calculateLevelMiters(siblings.map(exaggerateWallThickness))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage C floor-plan builder for wall — emits the full chrome stack the
|
* Stage C floor-plan builder for wall — emits the full chrome stack the
|
||||||
* legacy `floorplan-panel.tsx` rendered inline:
|
* legacy `floorplan-panel.tsx` rendered inline:
|
||||||
@@ -47,21 +57,25 @@ function formatLengthMetric(meters: number): string {
|
|||||||
* layer hosts the 5-circle stack + hover transitions + 2D drag.
|
* layer hosts the 5-circle stack + hover transitions + 2D drag.
|
||||||
* 5. A small dimension label at the midpoint when selected.
|
* 5. A small dimension label at the midpoint when selected.
|
||||||
*
|
*
|
||||||
* `ctx.siblings` provides other walls in the level so
|
* `ctx.levelData` provides the shared level miter graph when the floor-plan
|
||||||
* `calculateLevelMiters` computes correct corner joins.
|
* dispatcher precomputes it; `ctx.siblings` remains the fallback path for
|
||||||
*
|
* direct builder callers.
|
||||||
* Performance note: this recomputes level miter data per wall (O(N²)
|
|
||||||
* across N walls in the level). For < 100 walls per level this is
|
|
||||||
* sub-millisecond. If a real perf hotspot surfaces, the
|
|
||||||
* `ctx.levelData?.miters` extension flagged in the plan moves the batch
|
|
||||||
* computation to the dispatcher.
|
|
||||||
*/
|
*/
|
||||||
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
|
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||||
const siblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall')
|
const self = exaggerateWallThickness(node)
|
||||||
const all = [node, ...siblings].map(exaggerateWallThickness)
|
// Prefer the level-batch miter graph the floor-plan dispatcher precomputes
|
||||||
const miters = calculateLevelMiters(all)
|
// once per pass (`computeWallFloorplanLevelData`). Only the fallback path —
|
||||||
const self = all.find((w) => w.id === node.id)
|
// a direct builder caller with no shared data — pays the O(N) exaggerate +
|
||||||
if (!self) return null
|
// level-wide miter calc per wall; the dispatcher path is O(1) here, which is
|
||||||
|
// what keeps a wall drag from being O(N²) across the level.
|
||||||
|
const miters =
|
||||||
|
(ctx.levelData as WallMiterData | undefined) ??
|
||||||
|
calculateLevelMiters([
|
||||||
|
self,
|
||||||
|
...ctx.siblings
|
||||||
|
.filter((s): s is AnyNode & WallNode => s.type === 'wall')
|
||||||
|
.map(exaggerateWallThickness),
|
||||||
|
])
|
||||||
|
|
||||||
const polygon = getWallPlanFootprint(self, miters)
|
const polygon = getWallPlanFootprint(self, miters)
|
||||||
if (!polygon || polygon.length < 3) return null
|
if (!polygon || polygon.length < 3) return null
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
formatAngleRadians,
|
formatAngleRadians,
|
||||||
getAngleToSegmentReference,
|
getAngleToSegmentReference,
|
||||||
getSegmentAngleReferenceAtPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
isMagneticSnapActive,
|
||||||
isSegmentLongEnough,
|
isSegmentLongEnough,
|
||||||
MeasurementPill,
|
MeasurementPill,
|
||||||
type MovingWallEndpoint,
|
type MovingWallEndpoint,
|
||||||
@@ -295,7 +296,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
|||||||
walls: levelWalls,
|
walls: levelWalls,
|
||||||
ignoreWallIds: [nodeId],
|
ignoreWallIds: [nodeId],
|
||||||
bypassSnap,
|
bypassSnap,
|
||||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||||
})
|
})
|
||||||
const snappedPoint = snapResult.point
|
const snappedPoint = snapResult.point
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
getAngleArcToSegmentReference,
|
getAngleArcToSegmentReference,
|
||||||
getAngleToSegmentReference,
|
getAngleToSegmentReference,
|
||||||
getSegmentAngleReferenceAtPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
isAngleSnapActive,
|
||||||
|
isMagneticSnapActive,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
type SegmentAngleReference,
|
type SegmentAngleReference,
|
||||||
snapWallDraftPointDetailed,
|
snapWallDraftPointDetailed,
|
||||||
@@ -40,8 +42,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
|
|||||||
*
|
*
|
||||||
* 1:1 port of the legacy `WallTool`. Two-click flow: click 1 sets the
|
* 1:1 port of the legacy `WallTool`. Two-click flow: click 1 sets the
|
||||||
* start, click 2 creates the wall. Between clicks a vertical preview
|
* start, click 2 creates the wall. Between clicks a vertical preview
|
||||||
* rectangle + length/angle measurement HUD follow the pointer. Shift
|
* rectangle + length/angle measurement HUD follow the pointer. Snapping is
|
||||||
* bypasses the angle snap; Esc cancels.
|
* governed by the global snapping mode (`'off'` is the bypass); Esc cancels.
|
||||||
*
|
*
|
||||||
* Not a `DragAction` — same reasoning as fence/slab/ceiling placement:
|
* Not a `DragAction` — same reasoning as fence/slab/ceiling placement:
|
||||||
* stateful sequence of grid:click events, not a single drag-up.
|
* stateful sequence of grid:click events, not a single drag-up.
|
||||||
@@ -486,7 +488,6 @@ export const WallTool: React.FC = () => {
|
|||||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const buildingState = useRef(0)
|
const buildingState = useRef(0)
|
||||||
const shiftPressed = useRef(false)
|
|
||||||
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||||
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
|
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
|
||||||
const measurementColor = isDark ? '#ffffff' : '#111111'
|
const measurementColor = isDark ? '#ffffff' : '#111111'
|
||||||
@@ -508,13 +509,16 @@ export const WallTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Align the drafted point onto another object's nearest real anchor and
|
// Align the drafted point onto another object's nearest real anchor and
|
||||||
// publish the guide. Alt bypasses alignment; Shift bypasses all guided
|
// publish the guide. Alt bypasses alignment. Returns the possibly snapped
|
||||||
// snapping. Returns the possibly snapped point.
|
// point.
|
||||||
const alignPoint = (
|
const alignPoint = (
|
||||||
point: WallPlanPoint,
|
point: WallPlanPoint,
|
||||||
options: { applySnap?: boolean; bypass?: boolean },
|
options: { applySnap?: boolean; bypass?: boolean },
|
||||||
): WallPlanPoint => {
|
): WallPlanPoint => {
|
||||||
if (options.bypass || alignmentCandidates.length === 0) {
|
// Figma alignment pulls the endpoint onto existing wall corners / edges,
|
||||||
|
// so it is a line snap — suppress it whenever magnetic snap is off
|
||||||
|
// (`'off'` / `'angles'`), matching the wall-geometry snap above.
|
||||||
|
if (options.bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
return point
|
return point
|
||||||
}
|
}
|
||||||
@@ -546,19 +550,17 @@ export const WallTool: React.FC = () => {
|
|||||||
|
|
||||||
const walls = getCurrentLevelWalls()
|
const walls = getCurrentLevelWalls()
|
||||||
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
// Default path: grid + magnetic snap, with 15° angle lock while
|
// Snapping is governed entirely by the snapping mode (grid / lines /
|
||||||
// drafting. Shift is a hard snap bypass: no grid, magnetic, angle,
|
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
|
||||||
// or alignment snap.
|
// Alt still bypasses Figma-style alignment guides independently.
|
||||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
const angleLocked = buildingState.current === 1 && isAngleSnapActive()
|
||||||
const angleLocked = buildingState.current === 1 && !bypassSnap
|
const bypassAlign = event.nativeEvent?.altKey === true
|
||||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
|
||||||
const snapResult = snapWallDraftPointDetailed({
|
const snapResult = snapWallDraftPointDetailed({
|
||||||
point: localPoint,
|
point: localPoint,
|
||||||
walls,
|
walls,
|
||||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||||
angleSnap: angleLocked,
|
angleSnap: angleLocked,
|
||||||
bypassSnap,
|
magnetic: isMagneticSnapActive(),
|
||||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
|
||||||
})
|
})
|
||||||
gridPosition = alignPoint(snapResult.point, {
|
gridPosition = alignPoint(snapResult.point, {
|
||||||
applySnap: !angleLocked,
|
applySnap: !angleLocked,
|
||||||
@@ -590,7 +592,6 @@ export const WallTool: React.FC = () => {
|
|||||||
|
|
||||||
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
||||||
if (
|
if (
|
||||||
!bypassSnap &&
|
|
||||||
previousWallEnd &&
|
previousWallEnd &&
|
||||||
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
||||||
) {
|
) {
|
||||||
@@ -633,16 +634,14 @@ export const WallTool: React.FC = () => {
|
|||||||
const walls = getCurrentLevelWalls()
|
const walls = getCurrentLevelWalls()
|
||||||
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
|
|
||||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
const bypassAlign = event.nativeEvent?.altKey === true
|
||||||
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
|
|
||||||
|
|
||||||
if (buildingState.current === 0) {
|
if (buildingState.current === 0) {
|
||||||
const snappedStart = alignPoint(
|
const snappedStart = alignPoint(
|
||||||
snapWallDraftPointDetailed({
|
snapWallDraftPointDetailed({
|
||||||
point: localClick,
|
point: localClick,
|
||||||
walls,
|
walls,
|
||||||
bypassSnap,
|
magnetic: isMagneticSnapActive(),
|
||||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
|
||||||
}).point,
|
}).point,
|
||||||
{ bypass: bypassAlign },
|
{ bypass: bypassAlign },
|
||||||
)
|
)
|
||||||
@@ -665,15 +664,14 @@ export const WallTool: React.FC = () => {
|
|||||||
// `onGridMove` writes a real BoxGeometry skips that frame.
|
// `onGridMove` writes a real BoxGeometry skips that frame.
|
||||||
setDraftMeasurement(null)
|
setDraftMeasurement(null)
|
||||||
} else if (buildingState.current === 1) {
|
} else if (buildingState.current === 1) {
|
||||||
const angleLocked = !bypassSnap
|
const angleLocked = isAngleSnapActive()
|
||||||
const snappedEnd = alignPoint(
|
const snappedEnd = alignPoint(
|
||||||
snapWallDraftPointDetailed({
|
snapWallDraftPointDetailed({
|
||||||
point: localClick,
|
point: localClick,
|
||||||
walls,
|
walls,
|
||||||
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
||||||
angleSnap: angleLocked,
|
angleSnap: angleLocked,
|
||||||
bypassSnap,
|
magnetic: isMagneticSnapActive(),
|
||||||
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
|
|
||||||
}).point,
|
}).point,
|
||||||
{
|
{
|
||||||
applySnap: !angleLocked,
|
applySnap: !angleLocked,
|
||||||
@@ -729,20 +727,6 @@ export const WallTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Shift') shiftPressed.current = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Shift') shiftPressed.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
|
|
||||||
// angle lock isn't stuck off when focus returns.
|
|
||||||
const onBlur = () => {
|
|
||||||
shiftPressed.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
if (buildingState.current === 1) {
|
if (buildingState.current === 1) {
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
@@ -753,17 +737,11 @@ export const WallTool: React.FC = () => {
|
|||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
window.addEventListener('keyup', onKeyUp)
|
|
||||||
window.addEventListener('blur', onBlur)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
|
||||||
window.removeEventListener('keyup', onKeyUp)
|
|
||||||
window.removeEventListener('blur', onBlur)
|
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
useWallSnapIndicator.getState().clear()
|
useWallSnapIndicator.getState().clear()
|
||||||
useSegmentDraftChain.getState().clear('wall')
|
useSegmentDraftChain.getState().clear('wall')
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
|||||||
// Stage C: floor-plan polygon. ctx.parent gives the wall for direction
|
// Stage C: floor-plan polygon. ctx.parent gives the wall for direction
|
||||||
// + thickness — same shape as door.
|
// + thickness — same shape as door.
|
||||||
floorplan: buildWindowFloorplan,
|
floorplan: buildWindowFloorplan,
|
||||||
|
floorplanDependsOnSiblings: true,
|
||||||
// Stage D — placement + move-on-wall. Same recipe as door. See
|
// Stage D — placement + move-on-wall. Same recipe as door. See
|
||||||
// `nodes/src/window/{tool,move-tool,window-math}.ts`.
|
// `nodes/src/window/{tool,move-tool,window-math}.ts`.
|
||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
clampDoorOperationState,
|
clampDoorOperationState,
|
||||||
|
DEFAULT_WALL_THICKNESS,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
DoorNode as DoorNodeSchema,
|
DoorNode as DoorNodeSchema,
|
||||||
getDoorRenderOpenAmount,
|
getDoorRenderOpenAmount,
|
||||||
getEffectiveNode,
|
getEffectiveNode,
|
||||||
|
getWallThickness,
|
||||||
type SceneMaterial,
|
type SceneMaterial,
|
||||||
type SceneMaterialId,
|
type SceneMaterialId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
useScene,
|
useScene,
|
||||||
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
@@ -26,6 +29,7 @@ import {
|
|||||||
resolveMaterialRef,
|
resolveMaterialRef,
|
||||||
} from '../../lib/materials'
|
} from '../../lib/materials'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
|
||||||
|
|
||||||
// Invisible material for root mesh — used as selection hitbox only
|
// Invisible material for root mesh — used as selection hitbox only
|
||||||
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
||||||
@@ -186,6 +190,19 @@ function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh {
|
|||||||
return mesh
|
return mesh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_RAYCAST = () => {}
|
||||||
|
|
||||||
|
// An open door leaf swings perpendicular to the wall, so in a top-down view its
|
||||||
|
// flat panel blankets the room interior and wins the selection raycast over the
|
||||||
|
// slab/items beneath it. Drop the swung leaf out of the raycast so a click on
|
||||||
|
// the floor falls through to what's underneath; the door stays selectable via
|
||||||
|
// its proud invisible cutout proxy at the opening (see syncDoorCutout).
|
||||||
|
function disableSubtreeRaycast(object: THREE.Object3D) {
|
||||||
|
object.traverse((child) => {
|
||||||
|
;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
|
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
|
||||||
const slots = node.slots
|
const slots = node.slots
|
||||||
if (!slots) return false
|
if (!slots) return false
|
||||||
@@ -1323,6 +1340,14 @@ function addDoorLeaf(
|
|||||||
)
|
)
|
||||||
addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0)
|
addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the leaf is swung open it projects into the room and would otherwise
|
||||||
|
// win a top-down selection click over the floor beneath it. Drop only the
|
||||||
|
// swung leaf out of the raycast; a closed leaf stays in the wall plane and
|
||||||
|
// keeps its hit-eligibility (so paint-by-slot still works on it).
|
||||||
|
if (Math.abs(swingRotation) > 1e-3) {
|
||||||
|
disableSubtreeRaycast(leafGroup)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFoldingDoor(
|
function addFoldingDoor(
|
||||||
@@ -2556,18 +2581,21 @@ function hideEmptyGeometryMeshes(root: THREE.Object3D) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
||||||
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
|
// ── Cutout: invisible raycast hit target for the whole opening ──
|
||||||
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
||||||
if (!cutout) {
|
if (!cutout) {
|
||||||
cutout = new THREE.Mesh()
|
cutout = new THREE.Mesh()
|
||||||
cutout.name = 'cutout'
|
cutout.name = 'cutout'
|
||||||
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
|
// The cutout (invisible) is proud of the wall on both faces, so it wins the
|
||||||
// wins the scene raycast over the wall in front of the recessed door body —
|
// scene raycast over the wall in front of the recessed door body — making it
|
||||||
// making it the selection AND paint hit target for the whole opening. The
|
// the selection AND paint hit target for the whole opening. The paint
|
||||||
// paint capability then re-raycasts the door's parts to find the slot.
|
// capability then re-raycasts the door's parts to find the slot. Its depth
|
||||||
|
// is snug to the wall (not 1m) so it no longer blankets the room floor in a
|
||||||
|
// top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
|
||||||
mesh.add(cutout)
|
mesh.add(cutout)
|
||||||
}
|
}
|
||||||
cutout.geometry.dispose()
|
cutout.geometry.dispose()
|
||||||
|
const depth = resolveOpeningCutoutProxyDepth(node)
|
||||||
const openingShape = getEffectiveOpeningShape(node)
|
const openingShape = getEffectiveOpeningShape(node)
|
||||||
if (openingShape === 'arch') {
|
if (openingShape === 'arch') {
|
||||||
cutout.geometry = new THREE.ExtrudeGeometry(
|
cutout.geometry = new THREE.ExtrudeGeometry(
|
||||||
@@ -2579,12 +2607,12 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
getClampedArchHeight(node.width, node.height, node.archHeight),
|
getClampedArchHeight(node.width, node.height, node.archHeight),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
depth: 1,
|
depth,
|
||||||
bevelEnabled: false,
|
bevelEnabled: false,
|
||||||
curveSegments: 24,
|
curveSegments: 24,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cutout.geometry.translate(0, 0, -0.5)
|
cutout.geometry.translate(0, 0, -depth / 2)
|
||||||
} else if (openingShape === 'rounded') {
|
} else if (openingShape === 'rounded') {
|
||||||
cutout.geometry = new THREE.ExtrudeGeometry(
|
cutout.geometry = new THREE.ExtrudeGeometry(
|
||||||
createRoundedTopShape(
|
createRoundedTopShape(
|
||||||
@@ -2595,18 +2623,30 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
getDoorTopRadii(node, node.width, node.height),
|
getDoorTopRadii(node, node.width, node.height),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
depth: 1,
|
depth,
|
||||||
bevelEnabled: false,
|
bevelEnabled: false,
|
||||||
curveSegments: 24,
|
curveSegments: 24,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cutout.geometry.translate(0, 0, -0.5)
|
cutout.geometry.translate(0, 0, -depth / 2)
|
||||||
} else {
|
} else {
|
||||||
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
|
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
|
||||||
}
|
}
|
||||||
cutout.visible = false
|
cutout.visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the cutout proxy depth from the opening's parent wall thickness so
|
||||||
|
// the proxy stays proud of both wall faces (front/back selection) without the
|
||||||
|
// old 1m depth that blanketed the floor. Falls back to the default thickness
|
||||||
|
// when the parent wall isn't a resolvable wall node.
|
||||||
|
function resolveOpeningCutoutProxyDepth(node: DoorNode): number {
|
||||||
|
const parentId = node.parentId
|
||||||
|
const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
|
||||||
|
const wallThickness =
|
||||||
|
parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS
|
||||||
|
return getOpeningCutoutProxyDepth(wallThickness)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a fresh door mesh for preview/ghost rendering.
|
* Build a fresh door mesh for preview/ghost rendering.
|
||||||
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
|
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ export type OpeningCutoutRect = {
|
|||||||
top: number
|
top: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The cutout proxy doubles as the invisible raycast hit target for an opening:
|
||||||
|
// centered on the wall and extending past both faces so it wins the scene
|
||||||
|
// raycast over the recessed door/window body for front AND back selection +
|
||||||
|
// paint. It only needs to clear the wall thickness plus a small proud margin —
|
||||||
|
// the wall CSG brush ignores this proxy's depth entirely (it rebuilds its own
|
||||||
|
// full-thickness box from the proxy's X/Y bounds in `collectCutoutBrushes`), so
|
||||||
|
// a snug depth keeps the cut intact while no longer blanketing the room floor in
|
||||||
|
// a top-down view (the bug a 1m-deep proxy caused in narrow hallways).
|
||||||
|
const OPENING_CUTOUT_PROXY_PROUD_MARGIN = 0.08
|
||||||
|
|
||||||
|
export function getOpeningCutoutProxyDepth(wallThickness: number): number {
|
||||||
|
return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN
|
||||||
|
}
|
||||||
|
|
||||||
type CornerRadii = {
|
type CornerRadii = {
|
||||||
topLeft: number
|
topLeft: number
|
||||||
topRight: number
|
topRight: number
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
DEFAULT_WALL_THICKNESS,
|
||||||
getEffectiveNode,
|
getEffectiveNode,
|
||||||
|
getWallThickness,
|
||||||
type SceneMaterial,
|
type SceneMaterial,
|
||||||
type SceneMaterialId,
|
type SceneMaterialId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
useScene,
|
useScene,
|
||||||
|
type WallNode,
|
||||||
type WindowNode,
|
type WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
@@ -22,6 +25,7 @@ import {
|
|||||||
resolveMaterialRef,
|
resolveMaterialRef,
|
||||||
} from '../../lib/materials'
|
} from '../../lib/materials'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry'
|
||||||
|
|
||||||
// Invisible material for root mesh — used as selection hitbox only
|
// Invisible material for root mesh — used as selection hitbox only
|
||||||
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
||||||
@@ -163,6 +167,21 @@ function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh {
|
|||||||
return mesh
|
return mesh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_RAYCAST = () => {}
|
||||||
|
|
||||||
|
// An open casement sash swings perpendicular to the wall, so in a top-down view
|
||||||
|
// its flat panel blankets the room interior and wins the selection raycast over
|
||||||
|
// the slab/items beneath it. Drop the swung sash out of the raycast so a floor
|
||||||
|
// click falls through; the window stays selectable via its proud invisible
|
||||||
|
// cutout proxy at the opening (see syncWindowCutout). Skipped while closed so
|
||||||
|
// paint-by-slot still resolves on the sash.
|
||||||
|
function disableSubtreeRaycastIfSwung(object: THREE.Object3D, rotationY: number) {
|
||||||
|
if (Math.abs(rotationY) <= 1e-3) return
|
||||||
|
object.traverse((child) => {
|
||||||
|
;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
|
function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }): boolean {
|
||||||
const slots = node.slots
|
const slots = node.slots
|
||||||
if (!slots) return false
|
if (!slots) return false
|
||||||
@@ -1020,6 +1039,8 @@ function addRectCasementSash(
|
|||||||
)
|
)
|
||||||
currentWindowSlot = 'glass'
|
currentWindowSlot = 'glass'
|
||||||
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
|
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
|
||||||
|
|
||||||
|
disableSubtreeRaycastIfSwung(sash, rotationY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFrenchCasementHingeMarkers(
|
function addFrenchCasementHingeMarkers(
|
||||||
@@ -1244,6 +1265,7 @@ function addShapedFrenchCasementSash(
|
|||||||
sashDepth * 0.08,
|
sashDepth * 0.08,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
disableSubtreeRaycastIfSwung(sash, rotationY)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1271,6 +1293,7 @@ function addShapedFrenchCasementSash(
|
|||||||
sashDepth * 0.08,
|
sashDepth * 0.08,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
disableSubtreeRaycastIfSwung(sash, rotationY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
|
function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
|
||||||
@@ -1534,6 +1557,8 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disableSubtreeRaycastIfSwung(sash, sash.rotation.y)
|
||||||
|
|
||||||
currentWindowSlot = 'frame'
|
currentWindowSlot = 'frame'
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
mesh,
|
||||||
@@ -1696,6 +1721,8 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
currentWindowSlot = 'glass'
|
currentWindowSlot = 'glass'
|
||||||
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
|
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
|
||||||
|
|
||||||
|
disableSubtreeRaycastIfSwung(sash, sash.rotation.y)
|
||||||
|
|
||||||
// Small hinge markers make the pivot side legible when the sash is closed.
|
// Small hinge markers make the pivot side legible when the sash is closed.
|
||||||
currentWindowSlot = 'frame'
|
currentWindowSlot = 'frame'
|
||||||
addBox(
|
addBox(
|
||||||
@@ -3597,20 +3624,23 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
|
function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
|
||||||
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
|
// ── Cutout: invisible raycast hit target for the whole opening ──
|
||||||
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
||||||
if (!cutout) {
|
if (!cutout) {
|
||||||
cutout = new THREE.Mesh()
|
cutout = new THREE.Mesh()
|
||||||
cutout.name = 'cutout'
|
cutout.name = 'cutout'
|
||||||
// The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
|
// The cutout (invisible) is proud of the wall on both faces, so it wins the
|
||||||
// wins the scene raycast over the wall in front of the recessed window —
|
// scene raycast over the wall in front of the recessed window — making it
|
||||||
// making it the selection AND paint hit target for the whole opening. The
|
// the selection AND paint hit target for the whole opening. The paint
|
||||||
// paint capability then re-raycasts the window's parts to find the slot.
|
// capability then re-raycasts the window's parts to find the slot. Its depth
|
||||||
|
// is snug to the wall (not 1m) so it no longer blankets the room floor in a
|
||||||
|
// top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth).
|
||||||
mesh.add(cutout)
|
mesh.add(cutout)
|
||||||
}
|
}
|
||||||
cutout.geometry.dispose()
|
cutout.geometry.dispose()
|
||||||
|
const depth = resolveOpeningCutoutProxyDepth(node)
|
||||||
if (isRectangleOnlyWindowType(node)) {
|
if (isRectangleOnlyWindowType(node)) {
|
||||||
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
|
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
|
||||||
} else if (node.openingShape === 'arch') {
|
} else if (node.openingShape === 'arch') {
|
||||||
cutout.geometry = new THREE.ExtrudeGeometry(
|
cutout.geometry = new THREE.ExtrudeGeometry(
|
||||||
createArchShape(
|
createArchShape(
|
||||||
@@ -3621,12 +3651,12 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
getClampedArchHeight(node.width, node.height, node.archHeight),
|
getClampedArchHeight(node.width, node.height, node.archHeight),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
depth: 1,
|
depth,
|
||||||
bevelEnabled: false,
|
bevelEnabled: false,
|
||||||
curveSegments: 24,
|
curveSegments: 24,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cutout.geometry.translate(0, 0, -0.5)
|
cutout.geometry.translate(0, 0, -depth / 2)
|
||||||
} else if (node.openingShape === 'rounded') {
|
} else if (node.openingShape === 'rounded') {
|
||||||
cutout.geometry = new THREE.ExtrudeGeometry(
|
cutout.geometry = new THREE.ExtrudeGeometry(
|
||||||
createRoundedShape(
|
createRoundedShape(
|
||||||
@@ -3637,18 +3667,30 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
getWindowRoundedRadii(node, node.width, node.height),
|
getWindowRoundedRadii(node, node.width, node.height),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
depth: 1,
|
depth,
|
||||||
bevelEnabled: false,
|
bevelEnabled: false,
|
||||||
curveSegments: 24,
|
curveSegments: 24,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cutout.geometry.translate(0, 0, -0.5)
|
cutout.geometry.translate(0, 0, -depth / 2)
|
||||||
} else {
|
} else {
|
||||||
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
|
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth)
|
||||||
}
|
}
|
||||||
cutout.visible = false
|
cutout.visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the cutout proxy depth from the opening's parent wall thickness so
|
||||||
|
// the proxy stays proud of both wall faces (front/back selection) without the
|
||||||
|
// old 1m depth that blanketed the floor. Falls back to the default thickness
|
||||||
|
// when the parent wall isn't a resolvable wall node.
|
||||||
|
function resolveOpeningCutoutProxyDepth(node: WindowNode): number {
|
||||||
|
const parentId = node.parentId
|
||||||
|
const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
|
||||||
|
const wallThickness =
|
||||||
|
parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS
|
||||||
|
return getOpeningCutoutProxyDepth(wallThickness)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a fresh window mesh for preview/ghost rendering.
|
* Build a fresh window mesh for preview/ghost rendering.
|
||||||
* Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware).
|
* Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware).
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
|
|||||||
| [item-authoring](item-authoring.md) | Content-author contract for catalog item GLBs: `slot_` material naming, authored defaults + `pascal_material` extras, the `cutout` reserved mesh, UV world scale, and the validated Blender/export recipe |
|
| [item-authoring](item-authoring.md) | Content-author contract for catalog item GLBs: `slot_` material naming, authored defaults + `pascal_material` extras, the `cutout` reserved mesh, UV world scale, and the validated Blender/export recipe |
|
||||||
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
|
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
|
||||||
| [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults |
|
| [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults |
|
||||||
|
| [interaction-scope](interaction-scope.md) | The authoritative interaction state machine ("the spine"): `InteractionScope` union, the begin/update/end/endIf contract, the raycast hot-set, and the overlay scope matrix |
|
||||||
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
|
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
|
||||||
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
|
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
|
||||||
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
|
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
|
||||||
@@ -25,4 +26,5 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
|
|||||||
## Reading order for an architecture review
|
## Reading order for an architecture review
|
||||||
|
|
||||||
1. [layers](layers.md), [systems](systems.md), [renderers](renderers.md), [tools](tools.md), [viewer-isolation](viewer-isolation.md) — required every review.
|
1. [layers](layers.md), [systems](systems.md), [renderers](renderers.md), [tools](tools.md), [viewer-isolation](viewer-isolation.md) — required every review.
|
||||||
|
- When the diff touches placement / move / handle / reshape / box-select / paint or any overlay or picking behaviour, also read [interaction-scope](interaction-scope.md).
|
||||||
2. The remaining pages on demand, based on what the diff touches.
|
2. The remaining pages on demand, based on what the diff touches.
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Interaction Scope
|
||||||
|
|
||||||
|
*The authoritative interaction state machine ("the spine") — one scope describes "what the user is currently doing".*
|
||||||
|
|
||||||
|
Applies to: `packages/editor/src/lib/interaction/**`, `packages/editor/src/store/use-interaction-scope.ts`.
|
||||||
|
|
||||||
|
Before this, "what is the user doing right now?" was re-derived from 7+ independent
|
||||||
|
`useEditor` flags (`movingNode`, `placementDragMode`, `activeHandleDrag`,
|
||||||
|
`curvingWall`, `curvingFence`, `editingHole`, `movingWallEndpoint`,
|
||||||
|
`movingFenceEndpoint`). Every overlay and pick site re-derived its behaviour from
|
||||||
|
a different subset, so the flags could drift into illegal combinations (moving +
|
||||||
|
curving at once; a stale `movingNode` after a drag ended). The scope collapses
|
||||||
|
them into one discriminated union, making those combinations unrepresentable: a
|
||||||
|
scope is exactly one interaction at a time, and `idle` carries no payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
`InteractionScope` (`lib/interaction/scope.ts`) is a discriminated union on `kind`:
|
||||||
|
|
||||||
|
| `kind` | Payload | What |
|
||||||
|
|---|---|---|
|
||||||
|
| `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. |
|
||||||
|
| `placing` | `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. |
|
||||||
|
| `moving` | `nodeId`, `nodeType`, `view` | Moving an existing node. |
|
||||||
|
| `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. |
|
||||||
|
| `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). |
|
||||||
|
| `reshaping` | `nodeId`, `reshape`, `holeIndex?` | Reshaping a selected node's geometry. `reshape` is `curve \| hole \| endpoint \| boundary`. |
|
||||||
|
| `box-select` | — | Marquee selection drag. |
|
||||||
|
| `painting` | — | Material paint application. |
|
||||||
|
|
||||||
|
`reshaping` groups endpoint/curve/hole/boundary edits as sub-states of one scope
|
||||||
|
(rather than four sibling kinds) — there is one node and one in-flight reshape,
|
||||||
|
so "curving and hole-editing at once" stays unrepresentable. `view` is `'2d' | '3d'`.
|
||||||
|
|
||||||
|
### Helpers
|
||||||
|
|
||||||
|
- `isIdle(scope)` / `isActive(scope)` — `idle` vs anything else (`ActiveInteractionScope`).
|
||||||
|
- `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node.
|
||||||
|
- `selectionEnabled(scope)` — true only while `idle`. During any active interaction the pointer belongs to that interaction's body, not to selecting a different object; the picking choke point must not route a hover/click to selection while this is false.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The store contract
|
||||||
|
|
||||||
|
`useInteractionScope` (default export of `store/use-interaction-scope.ts`) is the
|
||||||
|
single owner. Exactly one scope at a time; the only writable shape is
|
||||||
|
`InteractionScope`, so there is no setter that can leave a half-state.
|
||||||
|
|
||||||
|
| Method | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `begin(scope: ActiveInteractionScope)` | Enter an interaction. If one is already active it is replaced (single owner, no producer races). |
|
||||||
|
| `update(patch)` | Patch the current scope's payload. **Ignored when idle, and ignored when the patch's `kind` differs from the active kind** — payload updates must not change which interaction is running (use `begin` for that). |
|
||||||
|
| `end()` | Return to idle atomically. Both commit and cancel call it; the write-vs-revert distinction lives in the interaction body, not here. |
|
||||||
|
| `endIf(match)` | Return to idle only if the active scope satisfies `match`. |
|
||||||
|
|
||||||
|
**Atomic-end invariant.** `end()` sets the scope back to `IDLE_SCOPE` in one
|
||||||
|
write — no interaction payload can leak past the end of its interaction (no stale
|
||||||
|
`nodeId`, no half-cleared flags). `endIf` exists because scope is currently
|
||||||
|
driven from independent legacy flag clears (below): clearing one flag (e.g. a
|
||||||
|
fence curve) must not stomp an unrelated active scope (e.g. a wall move), so the
|
||||||
|
clear only ends the scope if it owns it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hot-set: what is raycast-eligible during an interaction
|
||||||
|
|
||||||
|
`lib/interaction/hot-set.ts` answers "which scene objects can the active
|
||||||
|
interaction target?" It is never hand-authored per interaction — it falls out of
|
||||||
|
the node's `asset.attachTo` plus whether a candidate exposes a top surface.
|
||||||
|
|
||||||
|
`attachClassOf(attachTo)` collapses attachment to three `AttachClass` values:
|
||||||
|
|
||||||
|
- `wall` — `attachTo` of `wall` or `wall-side`.
|
||||||
|
- `ceiling` — `attachTo` of `ceiling`.
|
||||||
|
- `surface` — everything else ("floor item" really means *surface-resting*: rests on the floor **or** any host's top surface).
|
||||||
|
|
||||||
|
`isPickableForAttach(placed, candidate)` decides, for a node of attach class
|
||||||
|
`placed`, whether a `HotSetCandidate` is a valid host/surface:
|
||||||
|
|
||||||
|
- `wall` → only `wall` candidates.
|
||||||
|
- `ceiling` → only `ceiling` candidates.
|
||||||
|
- `surface` → the floor (`isFloorLike`), or any candidate that `exposesTop` (registry `capabilities.surfaces.top`) — but **never** a ceiling-mounted host. A floor lamp must not land on a ceiling fan; a ceiling fan's `attachClass` is `ceiling` and is excluded as a host top (Track E).
|
||||||
|
|
||||||
|
`isCandidateInHotSet(scope, placedAttachClass, candidate)` lifts this to a whole scope:
|
||||||
|
|
||||||
|
- `idle` → `true` (selection/phase filtering stays in the selection manager; the hot-set only narrows what an *active* interaction can target).
|
||||||
|
- `placing` / `moving` → `isPickableForAttach`, or `true` when `placedAttachClass` is `null`.
|
||||||
|
- every other active scope → `false`: nothing in the scene is a placement target, so the interaction body's own raycast owns the pointer.
|
||||||
|
|
||||||
|
`HotSetCandidate` (`type`, `isFloorLike`, `exposesTop`, `attachClass`) is derived
|
||||||
|
from the candidate node + its registry definition by the caller, keeping this
|
||||||
|
module pure and unit-testable without the scene or registry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overlay policy: the scope matrix
|
||||||
|
|
||||||
|
`resolveOverlayPolicy(scope)` (`lib/interaction/overlay-policy.ts`) returns the
|
||||||
|
"Sims-light" overlay behaviour: default-off, opt-in for the active action. During
|
||||||
|
any non-idle scope, scene objects stay visible but non-pickable, and DOM/HUD
|
||||||
|
overlays step back differentiated by how distracting they are.
|
||||||
|
|
||||||
|
| Overlay | Idle | Any active scope |
|
||||||
|
|---|---|---|
|
||||||
|
| Zone labels | shown | hidden (not a primary editing concern) |
|
||||||
|
| Context badges (hover name pills) | shown | faded + `pointer-events: none` |
|
||||||
|
| Conflicting controls (other objects' handles, floating action menu) | shown | hidden |
|
||||||
|
| Scene objects pickable | yes | no (the hot-set owns targeting; context preserved, can't grab the wrong thing) |
|
||||||
|
| Active affordances (ghost, snap guides, dimension labels, the active handle) | shown | shown |
|
||||||
|
| Contextual control HUD interactive | yes | yes (it *is* the active interaction's own controls — exempt from the pointer-events step-back) |
|
||||||
|
|
||||||
|
The policy is binary (`IDLE_POLICY` vs `ACTIVE_POLICY`) keyed on `isActive`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration status (strangler fig)
|
||||||
|
|
||||||
|
The scope is the target source of truth, but the legacy `useEditor` flags still
|
||||||
|
exist as a mirror and are being retired reader-by-reader. Today the scope is
|
||||||
|
**driven from** the central `useEditor` setters — `setMovingNode`,
|
||||||
|
`setActiveHandleDrag`, `setCurvingWall`/`setCurvingFence`, `setEditingHole`,
|
||||||
|
`setMovingWallEndpoint`/`setMovingFenceEndpoint`, `setMode` (for painting) — and
|
||||||
|
from the box-select tool. Each setter calls `begin`/`end` (and `endIf`, so an
|
||||||
|
independent flag clear can't stomp an unrelated scope) to keep the scope in sync.
|
||||||
|
|
||||||
|
**Contributors:**
|
||||||
|
|
||||||
|
- Add a new interaction by calling `begin(...)` / `end()` on `useInteractionScope`, **not** by adding a new `useEditor` flag.
|
||||||
|
- Read "what the user is doing" through the scope and its helpers (`isActive`, `scopeNodeId`, `selectionEnabled`), not by recombining flags. New readers should consume the scope so the legacy flag can be deleted once it has no readers.
|
||||||
|
- Add a new attach behaviour by setting `attachTo` on the asset — the hot-set follows with zero per-kind wiring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **One owner, one scope.** Only `useInteractionScope` writes the scope, and only via `begin`/`update`/`end`/`endIf`. Never reconstruct interaction state from a private combination of flags.
|
||||||
|
- **`end` is atomic and payload-free.** Never leave a `nodeId`/payload behind on idle; commit-vs-revert logic belongs in the interaction body before `end`.
|
||||||
|
- **`update` cannot change `kind`.** Switching interactions is a `begin`, not a patch.
|
||||||
|
- **Hot-set and overlay policy are pure derivations of the scope** (and, for the hot-set, the candidate metadata). Don't branch overlay/picking behaviour on legacy flags — branch on the scope.
|
||||||
|
- **Don't add new `useEditor` interaction flags.** New interactions go through the scope.
|
||||||
@@ -36,6 +36,12 @@ return <mesh ref={ref} {...events} />
|
|||||||
|
|
||||||
Events are suppressed during camera drag (`useViewer.getState().cameraDragging`).
|
Events are suppressed during camera drag (`useViewer.getState().cameraDragging`).
|
||||||
|
|
||||||
|
Selection/hover picking is only meaningful while the interaction scope is `idle`
|
||||||
|
(`selectionEnabled(scope)`). During an active placement/move/etc., the pointer
|
||||||
|
belongs to that interaction's body and the hot-set narrows which scene objects
|
||||||
|
are raycast-eligible — see [interaction-scope](interaction-scope.md) for the
|
||||||
|
hot-set derivation and the overlay scope matrix.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Viewer Selection Manager
|
## Viewer Selection Manager
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Tools are React components that capture user input (pointer, keyboard) and trans
|
|||||||
|
|
||||||
See `apps/editor/components/tools/tool-manager.tsx`.
|
See `apps/editor/components/tools/tool-manager.tsx`.
|
||||||
|
|
||||||
|
> **What the user is doing right now** is owned by the interaction state machine, not by tool-local flags. A tool that starts a placement / move / handle / reshape / box-select / paint interaction enters it through `useInteractionScope.begin(...)` and leaves through `end()` — see [interaction-scope](interaction-scope.md). Do not add a new `useEditor` flag for a new interaction.
|
||||||
|
|
||||||
## Tool Categories by Phase
|
## Tool Categories by Phase
|
||||||
|
|
||||||
**Site**
|
**Site**
|
||||||
|
|||||||
Reference in New Issue
Block a user