diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 99fb55d9..b23495c6 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -4,7 +4,7 @@ import { nodeRegistry } from '@pascal-app/core' import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { Tooltip, TooltipContent, @@ -152,15 +152,19 @@ function activateRoofFeatureTool(kind: string): void { * with the kind's own `def.defaults()`. The "Painting" type swaps in the * material-paint panel. */ +// MEP tool kinds that, when active, mean the MEP group tile (and its sub-grid) +// is what the user is working in. +const MEP_TOOL_KINDS = new Set([ + ...MEP_ITEMS.map((item) => item.kind), + 'duct-fitting', + 'pipe-fitting', +]) + export function BuildTab() { const activeTool = useEditor((s) => s.tool) const mode = useEditor((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) - // Which build tile's panel is showing. Roof (Features) and MEP (its tool - // sub-grid) are the tiles with a panel; others arm a tool and show nothing - // below. - const [selectedTypeId, setSelectedTypeId] = useState(null) // The fitting / follow tools are armed from a segment's panel, not a grid // tile — keep the segment tile lit so the panel (and the way back) stays @@ -200,8 +204,23 @@ export function BuildTab() { return features }, []) - const isTypeActive = (type: BuildType) => - type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id + // Tile highlight derives from the single source of truth (the active tool / + // mode), never a separate local selection — so keyboard shortcuts and panel + // clicks always agree on which tile is lit. + // The roof Features sub-grid arms roof-accessory tools (skylight, chimney, + // …); keep the Roof tile lit (and its panel open) while any of them is the + // active tool, the same way MEP stays lit for its sub-grid tools. + const isRoofFeatureActive = + mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool) + const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool) + + const isTypeActive = (type: BuildType) => { + if (type.mode === 'material-paint') return mode === 'material-paint' + if (type.id === 'mep') return isMepActive + if (type.id === 'roof') + return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive) + return mode === 'build' && activeTool === type.kind + } const handleTypeClick = useCallback((type: BuildType) => { if (type.mode === 'material-paint') { @@ -213,15 +232,18 @@ export function BuildTab() { } else if (type.kind) { activateBuildTool(type.kind) } - setSelectedTypeId(type.id) }, []) // On open, land on the first build tool — parity with the community Build - // sidebar, so switching to Build immediately arms a usable tool. + // sidebar, so switching to Build immediately arms a usable tool. Skip when a + // build tool is already active (e.g. the B shortcut armed one before this + // panel mounted): the active tool is the source of truth, not this default. const didInitRef = useRef(false) useEffect(() => { if (didInitRef.current) return didInitRef.current = true + const ed = useEditor.getState() + if (ed.mode === 'build' && ed.tool) return const firstType = BUILD_TYPES.find((t) => t.kind) if (firstType) handleTypeClick(firstType) }, [handleTypeClick]) @@ -274,7 +296,9 @@ export function BuildTab() {
- ) : selectedTypeId === 'roof' && roofFeatures.length > 0 ? ( + ) : mode === 'build' && + (activeTool === 'roof' || isRoofFeatureActive) && + roofFeatures.length > 0 ? (
Features
@@ -319,7 +343,7 @@ export function BuildTab() {
- ) : selectedTypeId === 'mep' ? ( + ) : isMepActive ? (
MEP
diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 22a5fa2c..7f0c10ea 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import { CeilingNode, SlabNode, WallNode } from '../schema' -import { planAutoCeilingsForLevel } from './space-detection' +import { planAutoCeilingsForLevel, planAutoSlabsForLevel } from './space-detection' const square: Array<[number, number]> = [ [0, 0], @@ -90,3 +90,26 @@ describe('planAutoCeilingsForLevel', () => { expect(plan.update).toHaveLength(0) }) }) + +describe('planAutoSlabsForLevel', () => { + test('matches two identical rooms to their own existing auto-slabs without churn', () => { + // Two rooms with identical polygon signatures previously collided in a + // signature-keyed Map, so one detected room never matched an existing slab + // and churned (delete + recreate) on every pass. + const slabA = slab(0.05) + const slabB = slab(0.05) + + const plan = planAutoSlabsForLevel([roomPolygon(), roomPolygon()], [slabA, slabB]) + + expect(plan.create).toHaveLength(0) + expect(plan.delete).toHaveLength(0) + expect(plan.update).toHaveLength(0) + }) + + test('deletes an extra auto-slab when only one identical room is detected', () => { + const plan = planAutoSlabsForLevel([roomPolygon()], [slab(0.05), slab(0.05)]) + + expect(plan.create).toHaveLength(0) + expect(plan.delete).toHaveLength(1) + }) +}) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 79e69c86..3b974a73 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -595,43 +595,25 @@ function levelWallSnapshot(walls: WallNode[]) { return walls.map(wallGeometrySignature).sort().join('||') } -function slabGeometrySignature(slab: SlabNodeType) { - const polygon = slab.polygon - .map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`) - .join(';') - const holes = (slab.holes ?? []) - .map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';')) - .join('/') - - return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join( - '|', - ) -} - -function levelSlabSnapshot(slabs: SlabNodeType[]) { - return slabs.map(slabGeometrySignature).sort().join('||') -} - +// Trigger signature is wall-only on purpose: re-detection should fire on a +// genuine remodel (wall geometry change), never when an auto-slab is edited or +// deleted. Hashing slabs here created a feedback loop where deleting an +// auto-slab re-fired detection and recreated it. function levelStructureSnapshots(nodes: Record) { - const byLevel = new Map() - const getEntry = (levelId: string) => { - const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] } - byLevel.set(levelId, entry) - return entry - } + const byLevel = new Map() for (const node of Object.values(nodes)) { if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue - if ((node as any).type === 'wall') { - getEntry((node as any).parentId).walls.push(node as WallNode) - } else if ((node as any).type === 'slab') { - getEntry((node as any).parentId).slabs.push(SlabNode.parse(node)) - } + if ((node as any).type !== 'wall') continue + const levelId = (node as any).parentId as string + const walls = byLevel.get(levelId) ?? [] + walls.push(node as WallNode) + byLevel.set(levelId, walls) } const snapshots = new Map() - for (const [levelId, entry] of byLevel.entries()) { - snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`) + for (const [levelId, walls] of byLevel.entries()) { + snapshots.set(levelId, levelWallSnapshot(walls)) } return snapshots @@ -692,13 +674,15 @@ export function planAutoSlabsForLevel( const matchedDetectedIdx = new Set() const updatesById = new Map() - const autoBySignature = new Map() + const autoBySignature = new Map>() for (const entry of existingAutoMeta) { - autoBySignature.set(entry.sig, entry) + const bucket = autoBySignature.get(entry.sig) ?? [] + bucket.push(entry) + autoBySignature.set(entry.sig, bucket) } detected.forEach((room, index) => { - const existing = autoBySignature.get(room.sig) + const existing = autoBySignature.get(room.sig)?.shift() if (!existing) return matchedDetectedIdx.add(index) @@ -875,13 +859,15 @@ export function planAutoCeilingsForLevel( const matchedDetectedIdx = new Set() const updatesById = new Map() - const autoBySignature = new Map() + const autoBySignature = new Map>() for (const entry of existingAutoMeta) { - autoBySignature.set(entry.sig, entry) + const bucket = autoBySignature.get(entry.sig) ?? [] + bucket.push(entry) + autoBySignature.set(entry.sig, bucket) } detected.forEach((room, index) => { - const existing = autoBySignature.get(room.sig) + const existing = autoBySignature.get(room.sig)?.shift() if (!existing) return matchedDetectedIdx.add(index) diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index c12bbdfa..c5d1ad5e 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -15,9 +15,8 @@ import type { CloneNodesIntoOptions, Subtree } from './subtree' // door cutouts read parent wall — use `ctx` to resolve those references // without importing `useScene`. Builders stay pure and unit-testable. // -// Future extension: `levelData?: { miters?: ... }` for level-scoped batch -// data (wall mitering across an entire level). Decided alongside the wall -// migration off its dedicated system (Phase 3+). +// `levelData` carries level-scoped batch data (wall mitering across an +// entire level) from registry dispatchers into pure builders. export type GeometryContext = { /** Look up any node by ID. Returns undefined if the node doesn't exist. */ @@ -30,18 +29,16 @@ export type GeometryContext = { parent: AnyNode | null /** * Pre-computed level-batch data, populated by the dispatcher when the - * kind declares `def.computeLevelData`. Shared across every - * `def.geometry(node, ctx)` call in the same level batch within a - * single frame, so kinds whose geometry depends on cross-sibling - * data (wall mitering, gradient sky uniforms across a zone, etc.) - * don't pay an O(N²) recomputation cost. + * kind declares `def.computeLevelData` (3D) or + * `def.computeFloorplanLevelData` (2D). Shared across every builder call + * in the same level batch within a single frame/render pass, so kinds + * whose geometry depends on cross-sibling data (wall mitering, gradient + * sky uniforms across a zone, etc.) don't pay an O(N²) recomputation cost. * * Typed as `unknown` at the framework boundary — kinds cast to their - * own `LevelData` shape inside `def.geometry` (the same kind owns - * both the `computeLevelData` return shape and the `geometry` - * consumer, so the cast is internal). Only populated for `def. - * geometry` calls today; not used by `def.floorplan` (which already - * has cheap access to siblings through `ctx.siblings`). + * own `LevelData` shape inside `def.geometry` / `def.floorplan` (the + * same kind owns both the compute hook's return shape and the builder + * consumer, so the cast is internal). */ levelData?: unknown /** @@ -820,6 +817,21 @@ export type NodeDefinition> = { * runs once even when many walls are dirty in the same frame. */ computeLevelData?: (siblings: ReadonlyArray>) => 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> + nodes: Record + }) => unknown /** * Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits * plain `FloorplanGeometry` data (SVG-renderable) rather than three.js @@ -877,6 +889,12 @@ export type NodeDefinition> = { * unset and rely on the generic overlay path. */ floorplanMoveTarget?: FloorplanMoveTarget> + /** + * Geometry reads sibling/parent/child nodes (e.g. wall miters, opening + * dimensions); the floor-plan layer must rebuild it whenever a + * sibling-affecting node is being dragged live. + */ + floorplanDependsOnSiblings?: boolean /** * Optional hook letting a kind project the `useLiveNodeOverrides` map * into a fresh `nodes` snapshot before its `def.floorplan` builder diff --git a/packages/core/src/services/hosting.test.ts b/packages/core/src/services/hosting.test.ts index ee0607c5..55934aec 100644 --- a/packages/core/src/services/hosting.test.ts +++ b/packages/core/src/services/hosting.test.ts @@ -5,6 +5,7 @@ import type { AnyNodeDefinition, Capabilities, SceneApi } from '../registry/type import type { AnyNode, AnyNodeId } from '../schema/types' import { canAttach, + canHostOnTop, clampYToHostTop, getSurface, getTopSurfaceHeight, @@ -14,6 +15,16 @@ import { const id = (s: string) => s as AnyNodeId +function makeItem(idStr: string, attachTo?: 'wall' | 'wall-side' | 'ceiling'): AnyNode { + return { + id: id(idStr), + type: 'item', + parentId: null, + visible: true, + asset: attachTo ? { attachTo } : {}, + } as unknown as AnyNode +} + function makeDef( kind: string, capabilities: Capabilities = {}, @@ -233,4 +244,30 @@ describe('pickHost', () => { }) expect(picked?.id).toBe(id('s2')) }) + + test('excludes ceiling-mounted hosts (ceiling fan cannot be a top surface)', () => { + registerNode(makeDef('item', { hostable: { parents: ['*'] } })) + const candidates = [makeItem('fan', 'ceiling'), makeItem('table')] + const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' }) + expect(picked?.id).toBe(id('table')) + }) + + test('keeps wall-mounted hosts (wall shelf still hosts)', () => { + registerNode(makeDef('item', { hostable: { parents: ['*'] } })) + const candidates = [makeItem('shelf', 'wall')] + const picked = pickHost({ point: [0, 0, 0], candidates, placedKind: 'item' }) + expect(picked?.id).toBe(id('shelf')) + }) +}) + +describe('canHostOnTop', () => { + test('rejects ceiling-attachTo hosts', () => { + expect(canHostOnTop(makeItem('fan', 'ceiling'))).toBe(false) + }) + + test('accepts wall / wall-side / floor (undefined) hosts', () => { + expect(canHostOnTop(makeItem('shelf', 'wall'))).toBe(true) + expect(canHostOnTop(makeItem('sconce', 'wall-side'))).toBe(true) + expect(canHostOnTop(makeItem('table'))).toBe(true) + }) }) diff --git a/packages/core/src/services/hosting.ts b/packages/core/src/services/hosting.ts index 6d15a69f..9394f586 100644 --- a/packages/core/src/services/hosting.ts +++ b/packages/core/src/services/hosting.ts @@ -112,6 +112,18 @@ export function getTopSurfaceHeight(host: AnyNode): number | null { return typeof height === 'function' ? height(host) : height } +/** + * Whether `host` can receive a surface-resting (top-stacked) child. A + * ceiling-mounted item hangs from the ceiling, so its visible "top" is not a + * usable resting surface — nothing should stack on a ceiling fan. The check + * reads the instance-level `asset.attachTo` (not the host KIND, which is shared + * across all items) so a single gate covers every interaction path. + */ +export function canHostOnTop(host: AnyNode): boolean { + const attachTo = (host as { asset?: { attachTo?: string } }).asset?.attachTo + return attachTo !== 'ceiling' +} + /** * Pure host-discovery helper. Given a list of candidate hosts (already * narrowed by spatial query) and a point, returns the first whose @@ -129,10 +141,7 @@ export function pickHost(args: { const def = nodeRegistry.get(host.type) const hostable = def?.capabilities.hostable if (!hostable) continue - if (hostable.parents.length > 0 && !hostable.parents.includes('*')) { - // capability declares specific parents; verify the placed kind's own def - // also permits this host kind. - } + if (!canHostOnTop(host)) continue if (args.hitTest && !args.hitTest(host, args.point)) continue return host } diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index f52f9394..a5b8fc0f 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -34,6 +34,7 @@ export { type AttachError, type AttachResult, canAttach, + canHostOnTop, clampYToHostTop, getSurface, getTopSurfaceHeight, diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index ada0b4a8..19525c10 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -48,6 +48,7 @@ export function FloorplanRegistryActionMenu() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined const movingNode = useEditor((s) => s.movingNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) // Gate on floorplan hover so this 2D menu never coexists with the 3D // FloatingActionMenu in split view — that menu hides while the floorplan // is hovered, so this one must only show then. Mirrors the legacy @@ -141,6 +142,11 @@ export function FloorplanRegistryActionMenu() { const handleMove = () => { sfxEmitter.emit('sfx:item-pick') setMovingNode(node as never) + // 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture. + // Mark the origin (after `setMovingNode`, which resets it to null) so + // `ToolManager` keeps the 3D affordance mover from also adopting the node + // and reverting it on unmount. Mirrors the orange move-dot path. + setMovingNodeOrigin('2d') // Match the legacy 3D `floating-action-menu`: clear selection so // selection-gated affordances unmount during the drag. Specifically // the slab / ceiling boundary editor (`ToolManager` shows it when diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 817bc42b..53a1317b 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -12,6 +12,8 @@ import { type GeometryContext, isRegistryMovable, kindsWithFloorplanScope, + type LiveNodeOverrides, + type LiveTransform, nodeRegistry, pauseSceneHistory, resolveBuildingForLevel, @@ -122,6 +124,46 @@ type RotationOverlayState = { sweep: number } +type FloorplanEntry = { + id: AnyNodeId + node: AnyNode + base: FloorplanGeometry | null + overlay: FloorplanGeometry | null + selected: boolean + highlighted: boolean +} + +type NodeDeps = { + node: AnyNode + live: LiveTransform | undefined + selected: boolean + highlighted: boolean + hovered: boolean + moving: boolean + palette: FloorplanPalette | undefined + siblingEpoch: number + committedNodes: Record | 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 + nodes: Record +}) => unknown + function snapshotNode(node: AnyNode): NodeSnapshot { // Shallow-clone every non-id, non-type field. Arrays / vec tuples are // deep-cloned to detach from the live store reference. @@ -137,6 +179,16 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) { return snapshots.map((s) => ({ id: s.id, data: s.data })) } +// Stable empty sentinels. While the floor plan is hidden (3D-only view) the +// live-* selectors return these instead of the real maps, so the per-pointer +// drag publishes (usePlacementCoordinator → useLiveTransforms / the rotate +// gizmo → useLiveNodeOverrides) no longer re-render this layer and its hundreds +// of geometry children. The same reference each call keeps zustand from +// detecting a change; committed scene edits still flow through `useScene`, so +// the plan is current the instant the view is shown again. +const EMPTY_LIVE_TRANSFORMS: Map = new Map() +const EMPTY_LIVE_OVERRIDES: Map = new Map() + export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const selectedLevelId = useViewer((s) => s.selection.levelId) const selectedBuildingId = useViewer((s) => s.selection.buildingId) @@ -191,6 +243,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const renderCtx = useFloorplanRender() const movingNode = useEditor((s) => s.movingNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) // Door / window placement (both build and move) needs the SVG's // background click handler to run — it finds the closest wall via // `findClosestWallPoint` and emits `wall:click` for the door / window @@ -215,17 +268,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { structureLayer !== 'zones' && !movingNode && !movingFenceEndpoint + // While the floor plan is not on screen (pure 3D view) it must not react to + // the per-pointer drag publishes below — re-rendering this layer + its + // hundreds of geometry children every move is what tanks 3D-drag framerate + // even though nothing 2D is visible. Gating the live-* subscriptions freezes + // them to a stable empty map while hidden; committed edits still arrive via + // `useScene`, so the plan is current the moment the view is shown. + const floorplanVisible = useEditor((s) => s.viewMode !== '3d') // Subscribe to the live-transforms map ref so the layer re-renders // whenever a 3D mover publishes a per-frame position (see // `usePlacementCoordinator`). Without this the 2D floor plan only // updates after 3D commit — the 3D drag would look frozen in 2D. - const liveTransforms = useLiveTransforms((s) => s.transforms) + const liveTransforms = useLiveTransforms((s) => + floorplanVisible ? s.transforms : EMPTY_LIVE_TRANSFORMS, + ) // Same reactivity hook for elevator runtime state — `useInteractive` // tracks the current / fallback level + cab travel, `useLiveNode // Overrides` carries live-edit overrides from the inspector. Builders // read both via `getState()` inside `def.floorplan`; subscribing here // is what forces the layer to re-render when they change. - const liveOverrides = useLiveNodeOverrides((s) => s.overrides) + const liveOverrides = useLiveNodeOverrides((s) => + floorplanVisible ? s.overrides : EMPTY_LIVE_OVERRIDES, + ) const interactiveElevators = useInteractive((s) => s.elevators) const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) @@ -239,6 +303,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const [hoveredHandleId, setHoveredHandleId] = useState(null) const [activeDragId, setActiveDragId] = useState(null) const [rotationOverlay, setRotationOverlay] = useState(null) + const geometryCacheRef = useRef>(new Map()) + const siblingEpochInputsRef = useRef([]) + const siblingEpochRef = useRef(0) const applyEntrySelection = useCallback( (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 // partition. const entries = useMemo(() => { - // Some builders read elevator runtime state imperatively; this keeps the memo subscribed. - void interactiveElevators + const previousCache = geometryCacheRef.current + const nextCache = new Map() + if (!levelId) { + geometryCacheRef.current = nextCache + return [] + } - if (!levelId) return [] - const out: { - id: AnyNodeId - node: AnyNode - base: FloorplanGeometry | null - overlay: FloorplanGeometry | null - selected: boolean - highlighted: boolean - }[] = [] + // The sibling epoch bumps whenever a sibling-affecting node's LIVE state + // changes (a wall/door/window/gutter being dragged or live-edited). Only + // flagged kinds feed it, so dragging or rotating a plain item — which also + // publishes to liveTransforms / liveOverrides — leaves it stable and the + // hundreds of wall/door geometries stay cached. Committed structural edits + // are covered separately by keying flagged kinds on the `nodes` ref. + const siblingEpochInputs: unknown[] = [] + for (const [id, live] of liveTransforms) { + const node = nodes[id as AnyNodeId] + if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { + siblingEpochInputs.push(live) + } + } + for (const [id, override] of liveOverrides) { + const node = nodes[id as AnyNodeId] + if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { + siblingEpochInputs.push(override) + } + } + if (!depsValueEqual(siblingEpochInputsRef.current, siblingEpochInputs)) { + siblingEpochRef.current += 1 + siblingEpochInputsRef.current = siblingEpochInputs + } + const siblingEpoch = siblingEpochRef.current + const out: FloorplanEntry[] = [] + const levelDataByType = new Map() + const levelNodeIdsByType = new Map() + + 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> + } + 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: (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 node = nodes[id] if (!node) return if ((node as { visible?: boolean }).visible === false) return - const def = nodeRegistry.get(node.type) - 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> - } - 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 }) - } - } + buildEntry(id, node) const childIds = (node as unknown as { children?: AnyNodeId[] }).children if (Array.isArray(childIds)) { for (const cid of childIds) visit(cid) @@ -607,50 +780,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const parentId = (node as { parentId?: AnyNodeId | null }).parentId if (parentId !== activeBuildingId) continue const cid = id as AnyNodeId - const def = nodeRegistry.get(node.type) - const builder = def?.floorplan - if (!builder) continue - const selected = selectedIdSet.has(cid) - const highlighted = highlightedIdSet.has(cid) - const hovered = hoveredId === cid - const moving = movingNode?.id === cid - const live = liveTransforms.get(cid) - const hasPosition = Array.isArray((node as { position?: unknown }).position) - let effectiveNode: AnyNode = - live && hasPosition ? applyPositionLiveTransform(node, live) : node - const contextNodes = def?.floorplanSiblingOverrides - ? def.floorplanSiblingOverrides({ nodeId: cid, nodes, liveOverrides }) - : nodes - if (contextNodes !== nodes) { - const merged = contextNodes[cid] - if (merged) { - effectiveNode = live && hasPosition ? applyPositionLiveTransform(merged, live) : merged - } - } - const ctx: GeometryContext = { - resolve: (rid: AnyNodeId): N | undefined => - contextNodes[rid] as N | undefined, + buildEntry(cid, node, { children: [], siblings: [], parent: activeLevelNode, - viewState: renderCtx?.palette - ? { - selected, - highlighted, - hovered, - moving, - palette: renderCtx.palette, - } - : undefined, - } - const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)( - effectiveNode, - ctx, - ) - if (geometry) { - const { base, overlay } = splitFloorplanOverlay(geometry) - out.push({ id: cid, node: effectiveNode, base, overlay, selected, highlighted }) - } + }) } } @@ -662,6 +796,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // DFS visit order (stable sort) so siblings keep their relative // priority. out.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type)) + geometryCacheRef.current = nextCache return out }, [ levelId, @@ -991,6 +1126,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { suppressBoxSelectForPointer(event) sfxEmitter.emit('sfx:item-pick') setMovingNode(node as never) + // Claim 2D ownership of this move at the source. `setMovingNode` + // resets the origin to null, so this must follow it. It gates the + // 3D affordance mover (`ToolManager`) off entirely: without it the + // 3D `MoveItemTool` would also mount, `adopt()` the same node, and + // restore its adopt-time (original) position from its unmount + // `destroy()` — snapping a committed 2D move back to its start. + setMovingNodeOrigin('2d') }} palette={palette} sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0} @@ -1947,6 +2089,7 @@ function buildContext( moving: boolean palette: FloorplanPalette | undefined }, + levelData?: unknown, ): GeometryContext { const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined @@ -1979,6 +2122,7 @@ function buildContext( children, siblings, parent, + levelData, viewState: viewState.palette ? { selected: viewState.selected, @@ -2071,6 +2215,37 @@ function splitFloorplanOverlay(g: FloorplanGeometry): { return { base: g, overlay: null } } +function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean { + const keys: Array = [ + 'node', + 'live', + 'selected', + 'highlighted', + 'hovered', + 'moving', + 'palette', + 'siblingEpoch', + 'committedNodes', + 'interactiveElevators', + ] + for (const key of keys) { + if (!depsValueEqual(a[key], b[key])) return false + } + return true +} + +function depsValueEqual(a: unknown, b: unknown): boolean { + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!Object.is(a[i], b[i])) return false + } + return true + } + return Object.is(a, b) +} + /** * Z-order bucket for floor-plan rendering. Lower rank = painted first = * sits under everything with a higher rank. SVG renders in document diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 6ae52550..fc610258 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -35,10 +35,12 @@ import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' +import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' import useEditor from '../../store/use-editor' +import useInteractionScope from '../../store/use-interaction-scope' import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' @@ -137,6 +139,11 @@ function getAttributeVersion( : 0 } +// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a +// dragged node doesn't allocate a fresh Box3 + Vector3 every frame. +const _anchorBox = new THREE.Box3() +const _anchorCenter = new THREE.Vector3() + function getObjectGeometryKey(object: THREE.Object3D): string { const parts: string[] = [] object.traverse((child) => { @@ -218,6 +225,10 @@ export function FloatingActionMenu() { const activeHandleDrag = useEditor((s) => s.activeHandleDrag) // R/T rotation axis for kinds with full 3D orientation (duct fittings). const rotationAxis = useEditor((s) => s.rotationAxis) + // The floating action menu is an action-conflicting control: hard-hidden + // during any active interaction so it never competes with the live action. + const scope = useInteractionScope((s) => s.scope) + const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden' const groupRef = useRef(null) const menuScaleRef = useRef(null) @@ -329,23 +340,44 @@ export function FloatingActionMenu() { // mid-resize). A spinning child changes the head's matrix, not the // registered group's, so it never triggers a recompute → the menu // holds still. + // Cheapest guards first: a selection swap, the object's own world + // transform changing (true every frame during a drag), a live override, + // or an active handle drag all force a recompute on their own — so skip + // the geometry traversal (`getObjectGeometryKey` walks the whole subtree + // reading attribute versions) until none of them fired and a + // geometry-only change is the only thing left that could move the anchor. const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null const dragActive = activeHandleDrag?.nodeId === selectedId - const effectiveNode = getEffectiveNode(node) - const geometryKey = getObjectGeometryKey(obj) const selectionChanged = lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld) - const geometryChanged = lastAnchorKeyRef.current.geometryKey !== geometryKey - if (selectionChanged || matrixChanged || geometryChanged || overrideActive || dragActive) { + let geometryKey = lastAnchorKeyRef.current.geometryKey + let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive + // Only when nothing cheaper fired do we pay for the subtree traversal — + // a geometry-only change is the lone remaining trigger. When a cheaper + // guard already forced a recompute the stored key is reused; the matrix + // (or override/drag) keeps recomputing the anchor every frame, so a + // geometry edit mid-drag is absorbed, and the next idle frame refreshes + // the key against the live geometry. + if (!needsRecompute) { + geometryKey = getObjectGeometryKey(obj) + if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true + } + + if (needsRecompute) { + const effectiveNode = getEffectiveNode(node) if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) { - const box = new THREE.Box3().setFromObject(obj) - if (!box.isEmpty()) { - const center = box.getCenter(new THREE.Vector3()) + _anchorBox.setFromObject(obj) + if (!_anchorBox.isEmpty()) { + _anchorBox.getCenter(_anchorCenter) // Position above the object. Per-type offsets clear each kind's // in-world chrome (height-resize arrows, measurement labels). - anchorRef.current.set(center.x, box.max.y + getMenuYOffset(effectiveNode), center.z) + anchorRef.current.set( + _anchorCenter.x, + _anchorBox.max.y + getMenuYOffset(effectiveNode), + _anchorCenter.z, + ) hasAnchorRef.current = true } } else { @@ -624,7 +656,8 @@ export function FloatingActionMenu() { !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || movingWallEndpoint || movingFenceEndpoint || - curvingFence + curvingFence || + menuStepBack ) return null diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 53a9d5ef..e8cc3c89 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -75,9 +75,11 @@ import { buildFloorplanItemEntry, buildFloorplanStairEntry as buildSharedFloorplanStairEntry, collectLevelDescendants, + floorplanLocalToWorldPoint, getFloorplanWall as getSharedFloorplanWall, rotatePlanVector as rotateSharedPlanVector, type FloorplanNodeTransform as SharedFloorplanNodeTransform, + worldToFloorplanLocalPoint, } from '../../lib/floorplan' import { guideEmitter } from '../../lib/guide-events' import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements' @@ -87,7 +89,11 @@ import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { cn } from '../../lib/utils' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' -import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor' +import useEditor, { + isAngleSnapActive, + isMagneticSnapActive, + selectSiteFloorplanContext, +} from '../../store/use-editor' import usePlacementPreview from '../../store/use-placement-preview' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' @@ -1793,39 +1799,6 @@ function cameraAzimuthFromFloorplanRotation(rotationDeg: number) { return degreesToRadians(rotationDeg + FLOORPLAN_VIEW_ROTATION_DEG) } -function floorplanLocalToWorldPoint( - point: SvgPoint | WallPlanPoint, - buildingPosition: readonly [number, number, number], - buildingRotationY: number, -): { x: number; z: number } { - const localX = Array.isArray(point) ? point[0] : point.x - const localY = Array.isArray(point) ? point[1] : point.y - const cos = Math.cos(buildingRotationY) - const sin = Math.sin(buildingRotationY) - - return { - x: buildingPosition[0] + localX * cos + localY * sin, - z: buildingPosition[2] - localX * sin + localY * cos, - } -} - -function worldToFloorplanLocalPoint( - worldX: number, - worldZ: number, - buildingPosition: readonly [number, number, number], - buildingRotationY: number, -): SvgPoint { - const dx = worldX - buildingPosition[0] - const dz = worldZ - buildingPosition[2] - const cos = Math.cos(buildingRotationY) - const sin = Math.sin(buildingRotationY) - - return { - x: dx * cos - dz * sin, - y: dx * sin + dz * cos, - } -} - function projectSvgPointToSurface( svgPoint: SvgPoint, viewBox: { minX: number; minY: number; width: number; height: number }, @@ -7678,7 +7651,7 @@ export function FloorplanPanel({ walls, ignoreWallIds: [dragState.wallId], bypassSnap, - magnetic: !bypassSnap && useEditor.getState().magneticSnap, + magnetic: !bypassSnap && isMagneticSnapActive(), }) const snappedPoint = snapResult.point // Magnetic beacon at the endpoint when it locked onto existing geometry. @@ -8537,30 +8510,30 @@ export function FloorplanPanel({ } if (isFenceBuildActive) { - const bypassSnap = shiftPressed || event.shiftKey // Fence draft: grid snap (+ existing-wall/fence endpoint snap), then // Figma alignment — same endpoint-wins precedence as the wall branch. - // While a draft is open the segment locks to 15° rays from its start - // unless Shift is held; Shift bypasses grid, magnetic, angle, and - // alignment snap. - const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap + // While a draft is open the segment locks to 15° rays from its start. + // Snapping is governed by the snapping mode (`'off'` is the bypass); + // there is no Shift hold-to-bypass. Alt still bypasses Figma alignment. + const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive() const fenceSnapped = snapFenceDraftPoint({ point: planPoint, walls, fences, start: fenceDraftStart ?? undefined, angleSnap: fenceAngleSnap, - bypassSnap, + magnetic: isMagneticSnapActive(), }) - const fenceGridBase = bypassSnap ? planPoint : snapWallPointToGrid(planPoint) + const fenceGridBase = snapWallPointToGrid(planPoint) const fenceLocked = - !bypassSnap && - (fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1]) + fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] let snappedPoint = fenceSnapped if (fenceLocked || fenceAngleSnap) useAlignmentGuides.getState().clear() else snappedPoint = alignFloorplanDraftPoint(fenceSnapped, { - bypass: event.altKey || bypassSnap, + // Alignment is a line snap (pulls onto existing corners/edges) — + // suppress it whenever magnetic snap is off (`'off'` / `'angles'`). + bypass: event.altKey || !isMagneticSnapActive(), }) emitFloorplanGridEvent('move', snappedPoint, event) @@ -8738,18 +8711,16 @@ export function FloorplanPanel({ } // Wall draft: grid + magnetic snap, then Figma-style alignment. - // While a draft is open the segment locks to 15° rays from its - // start unless Shift is held. Shift bypasses grid, magnetic, angle, - // and alignment snap. - const bypassSnap = shiftPressed || event.shiftKey - const wallAngleSnap = draftStart !== null && !bypassSnap + // While a draft is open the segment locks to 15° rays from its start. + // Snapping is governed by the snapping mode (`'off'` is the bypass); + // there is no Shift hold-to-bypass. Alt still bypasses Figma alignment. + const wallAngleSnap = draftStart !== null && isAngleSnapActive() const wallSnap = snapWallDraftPointDetailed({ point: planPoint, walls, start: draftStart ?? undefined, angleSnap: wallAngleSnap, - bypassSnap, - magnetic: !bypassSnap && useEditor.getState().magneticSnap, + magnetic: isMagneticSnapActive(), }) const wallSnapped = wallSnap.point // Locked onto existing geometry (corner / midpoint / crossing / edge) → @@ -8761,7 +8732,9 @@ export function FloorplanPanel({ } else { snappedPoint = alignFloorplanDraftPoint(wallSnapped, { applySnap: !wallAngleSnap, - bypass: event.altKey || bypassSnap, + // Alignment is a line snap (pulls onto existing corners/edges) — + // suppress it whenever magnetic snap is off (`'off'` / `'angles'`). + bypass: event.altKey || !isMagneticSnapActive(), }) } useWallSnapIndicator @@ -8780,8 +8753,9 @@ export function FloorplanPanel({ setDraftEnd((previousEnd) => { if ( - !bypassSnap && - (!previousEnd || previousEnd[0] !== snappedPoint[0] || previousEnd[1] !== snappedPoint[1]) + !previousEnd || + previousEnd[0] !== snappedPoint[0] || + previousEnd[1] !== snappedPoint[1] ) { sfxEmitter.emit('sfx:grid-snap') } @@ -9044,7 +9018,7 @@ export function FloorplanPanel({ angleSnap?: boolean bypassSnap?: boolean step?: number - }) => snapWallDraftPoint({ ...args, magnetic: useEditor.getState().magneticSnap }), + }) => snapWallDraftPoint({ ...args, magnetic: isMagneticSnapActive() }), [], ) const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({ diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index caf71ffe..07b9b6b6 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -269,15 +269,27 @@ export function createArrowHitAreaGeometry() { return geometry } +// The move cross is a plus, not a disk. A disk-shaped hit area fills the four +// corner gaps between the arms, so a neighbouring node sitting next to the +// selected node (a lamp by a door, a slab beside a wall) gets swallowed by the +// invisible grip and can't be picked. Wrap the visible arms instead: two flat +// arm boxes (length/width + margin) merged into a plus, leaving the corners +// empty so co-located neighbours stay selectable while the grip stays grabbable. function createMoveCrossHitAreaGeometry() { - const geometry = new CylinderGeometry( - MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, - MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, - HIT_AREA_THICKNESS, - 32, - ) - geometry.computeBoundingSphere() - return geometry + const armLength = (MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN) * 2 + const armWidth = (MOVE_CROSS_HEAD_HALF_WIDTH + HIT_AREA_MARGIN) * 2 + const armX = new BoxGeometry(armLength, HIT_AREA_THICKNESS, armWidth) + const armZ = new BoxGeometry(armWidth, HIT_AREA_THICKNESS, armLength) + const merged = mergeGeometries([armX, armZ], false) + if (!merged) { + armZ.dispose() + armX.computeBoundingSphere() + return armX + } + armX.dispose() + armZ.dispose() + merged.computeBoundingSphere() + return merged } export function createRotateArrowHitAreaGeometry() { diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 515dc048..14325f8c 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -43,6 +43,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' +import { ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' @@ -177,7 +178,6 @@ export function NodeArrowHandles() { const mode = useEditor((state) => state.mode) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const movingNode = useEditor((state) => state.movingNode) - const placementDragMode = useEditor((state) => state.placementDragMode) // Endpoint / curve drags reshape the selected wall or fence; hide its // resize arrows for the duration so they don't clutter (or get blocked // by) the drag's own cursor + dimension overlays. Mirrors the same guard @@ -203,9 +203,6 @@ export function NodeArrowHandles() { () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode), [rawNode, liveOverride], ) - const isOwnPressDragMove = - placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId - const def = node ? nodeRegistry.get(node.type) : null const descriptors = useMemo(() => { if (!(node && def?.handles)) return null @@ -218,7 +215,11 @@ export function NodeArrowHandles() { Boolean(node && descriptors?.length) && !isFloorplanHovered && mode !== 'delete' && - (!movingNode || isOwnPressDragMove) && + // Any whole-node move (placement or press-drag) hides the rig: the item is + // following the cursor, so its rotate/resize handles would only clutter and + // draw stray selection rays. The active handle-drag scope (resize/rotate) + // sets `activeHandleDrag`, not `movingNode`, so those are unaffected. + !movingNode && !movingWallEndpoint && !movingFenceEndpoint && !curvingWall && @@ -398,22 +399,30 @@ function NodeArrowHandlesForNode({ // resize that re-centres the mesh) must NOT fire for the non-active arrows // here, or they'd lag behind the moving item. const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate' + // While a rotate gizmo is mid-drag, drop the opposite-side move cross: you + // can't move and rotate at once, so it only clutters the rotation. + const activeDescriptor = activeIndex !== null ? descriptors[activeIndex] : undefined + const activeIsRotate = + !!activeDescriptor && 'shape' in activeDescriptor && activeDescriptor.shape === 'rotate' - const arrows = descriptors.map((descriptor, index) => ( - - )) + const arrows = descriptors.map((descriptor, index) => { + if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null + return ( + + ) + }) return createPortal( @@ -1135,8 +1144,21 @@ function ArcArrow({ } const initialAngle = angleOf(hitWorld) + // Advertise the rotate interaction so the contextual HUD can surface the + // Shift = free-rotation toggle (the angle-step bypass below). Resize + // handles route a measurement label here; rotate gets a sentinel label so + // the HUD shows the rotate hint, not a dimension pill. + if (isRotateShape) { + useEditor + .getState() + .setActiveHandleDrag({ nodeId: node.id, label: ROTATE_HANDLE_DRAG_LABEL }) + } + return { - onEnd: () => setRotationDelta(null), + onEnd: () => { + setRotationDelta(null) + if (isRotateShape) useEditor.getState().setActiveHandleDrag(null) + }, move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { const hit = new Vector3() if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index abb00e40..d3745439 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -6,10 +6,11 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import useAlignmentGuides from '../../store/use-alignment-guides' +import { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor' import usePlacementPreview from '../../store/use-placement-preview' import useSegmentDraftChain from '../../store/use-segment-draft-chain' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' -import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting' +import { getSegmentGridStep, type WallPlanPoint } from '../tools/wall/wall-drafting' type UseFloorplanBackgroundPlacementArgs = { activePolygonDraftPoints: WallPlanPoint[] @@ -212,8 +213,8 @@ export function useFloorplanBackgroundPlacement({ // start unless Shift is held; Shift bypasses grid, magnetic, // angle, and alignment snap. `gridSnap` keeps the regular snap // on the world XZ grid even when the building is rotated. - const fenceStep = WALL_GRID_STEP - const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap + const fenceStep = getSegmentGridStep() + const fenceAngleSnap = fenceDraftStart !== null && !bypassSnap && isAngleSnapActive() const fenceSnapped = snapFenceDraftPoint({ point: planPoint, walls, @@ -221,6 +222,7 @@ export function useFloorplanBackgroundPlacement({ start: fenceDraftStart ?? undefined, angleSnap: fenceAngleSnap, bypassSnap, + magnetic: !bypassSnap && isMagneticSnapActive(), gridSnap: (p) => worldGridSnap(p, fenceStep), }) const fenceGridBase = bypassSnap ? planPoint : worldGridSnap(planPoint, fenceStep) @@ -230,7 +232,9 @@ export function useFloorplanBackgroundPlacement({ const snappedPoint = fenceLocked || fenceAngleSnap ? fenceSnapped - : alignFloorplanDraftPoint(fenceSnapped, { bypass: event.altKey || bypassSnap }) + : alignFloorplanDraftPoint(fenceSnapped, { + bypass: event.altKey || bypassSnap || !isMagneticSnapActive(), + }) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) @@ -321,8 +325,8 @@ export function useFloorplanBackgroundPlacement({ // start unless Shift is held; Shift bypasses grid, magnetic, // angle, and alignment snap. `gridSnap` keeps the regular snap // on the world XZ grid even when the building is rotated. - const wallStep = WALL_GRID_STEP - const wallAngleSnap = draftStart !== null && !bypassSnap + const wallStep = getSegmentGridStep() + const wallAngleSnap = draftStart !== null && !bypassSnap && isAngleSnapActive() const wallSnapped = snapWallDraftPoint({ point: planPoint, walls, @@ -340,7 +344,10 @@ export function useFloorplanBackgroundPlacement({ } else { snappedPoint = alignFloorplanDraftPoint(wallSnapped, { applySnap: !wallAngleSnap, - bypass: event.altKey || bypassSnap, + // Figma alignment pulls the endpoint onto existing wall corners / + // edges, so it is a line snap — suppress it whenever magnetic snap + // is off (`'off'` / `'angles'`), matching the wall-geometry snap. + bypass: event.altKey || bypassSnap || !isMagneticSnapActive(), }) } diff --git a/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx b/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx index 9d2e4e36..00d85481 100644 --- a/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx +++ b/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx @@ -6,8 +6,10 @@ import { Check, Pencil } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' +import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' +import useInteractionScope from '../../../store/use-interaction-scope' // ─── Per-zone label editor ──────────────────────────────────────────────────── @@ -19,6 +21,10 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { const selectedZoneId = useViewer((s) => s.selection.zoneId) const hoveredId = useViewer((s) => s.hoveredId) const mode = useEditor((s) => s.mode) + // During an active interaction the zone label is a context badge that steps + // back: faded + non-interactive so it can't be hovered/clicked mid-action. + const scope = useInteractionScope((s) => s.scope) + const labelStepBack = resolveOverlayPolicy(scope).contextBadges === 'faded' const isSelected = selectedZoneId === zoneId const isDeleteHovered = mode === 'delete' && hoveredId === zoneId const [editing, setEditing] = useState(false) @@ -149,7 +155,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { fontSize: 14, fontFamily: 'sans-serif', userSelect: 'none', - pointerEvents: 'auto', + pointerEvents: labelStepBack ? 'none' : 'auto', + opacity: labelStepBack ? 0.4 : undefined, display: 'inline-flex', alignItems: 'center', gap: 4, diff --git a/packages/editor/src/components/systems/zone/zone-system.tsx b/packages/editor/src/components/systems/zone/zone-system.tsx index 98e68b6c..30bb0930 100644 --- a/packages/editor/src/components/systems/zone/zone-system.tsx +++ b/packages/editor/src/components/systems/zone/zone-system.tsx @@ -3,7 +3,9 @@ import { useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' import { type Group, MathUtils, type Mesh } from 'three' import type { MeshBasicNodeMaterial } from 'three/webgpu' +import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy' import useEditor from '../../../store/use-editor' +import useInteractionScope from '../../../store/use-interaction-scope' // Disable raycasting on zone geometry so clicks pass through to items underneath. // Zone selection in the editor is handled exclusively via the HTML label overlay. @@ -20,6 +22,11 @@ export const ZoneSystem = () => { // geometry or the HTML zone tags in the framed shot. const isCaptureMode = useEditor.getState().isCaptureMode + // During any active interaction zone labels step back entirely — they are + // not a primary editing concern and would distract / invite misclicks. + const zoneLabelsHidden = + resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden' + const zoneGeometryVisible = structureLayer === 'zones' const zones = sceneRegistry.byType.zone || new Set() const nodes = useScene.getState().nodes @@ -84,7 +91,8 @@ export const ZoneSystem = () => { // Labels: visible on the current level (regardless of mode), but never // during snapshot capture. - const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel + const showLabel = + !isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel const labelOpacity = showLabel ? '1' : '0' const labelEl = document.getElementById(`${zoneId}-label`) if (labelEl && labelEl.style.opacity !== labelOpacity) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 63c829ff..1d9dbf0c 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,9 +1,16 @@ import { type AssetInput, isObject } from '@pascal-app/core' import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' +import { resolveSnapFlags } from '../../../lib/snapping-mode' import useEditor from '../../../store/use-editor' +// Sentinel returned when the active snapping mode disables grid snapping. +// The snap helpers below treat any `step <= 0` as "no grid snap" and pass the +// raw value through. When grid snapping is enabled (the default `'grid'` mode) +// this returns the user's `gridSnapStep` exactly as before — so the default +// path is byte-identical to the pre-mode behaviour. function getGridSnapStep(): number { - return useEditor.getState().gridSnapStep + const state = useEditor.getState() + return resolveSnapFlags(state.snappingMode).grid ? state.gridSnapStep : 0 } function positiveModulo(value: number, divisor: number): number { @@ -14,6 +21,7 @@ function positiveModulo(value: number, divisor: number): number { * Snaps a position to the active grid step, aligning item edges to grid lines. */ export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number { + if (step <= 0) return position const halfDim = dimension / 2 const offset = positiveModulo(halfDim, step) return Math.round((position - offset) / step) * step + offset @@ -23,6 +31,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn * Snap a value to the active grid step (used for wall-local positions). */ export function snapToHalf(value: number, step = getGridSnapStep()): number { + if (step <= 0) return value return Math.round(value / step) * step } @@ -30,6 +39,7 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number { * Round a value up to the next multiple of `step`, with a minimum of `step`. */ export function snapUpToGridStep(value: number, step = getGridSnapStep()): number { + if (step <= 0) return value return Math.max(step, Math.ceil(value / step) * step) } diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index dcb00ed9..9bb2a2d1 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -16,6 +16,7 @@ import type { WallNode, } from '@pascal-app/core' import { + canHostOnTop, clampRectToRoofWallFace, getRoofSegmentWallFace, getScaledDimensions, @@ -64,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean { } function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) { + if (!canHostOnTop(surfaceItem)) return null if (isLowProfileItemSurface(surfaceItem)) return null if (!isUpwardItemSurfaceHit(event)) return null @@ -113,7 +115,7 @@ export const floorStrategy = { // is rotated; then project the world point back into building-local // for storage. Without this, a rotated building drags placement off // the world grid. - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const [x, z] = bypassSnap ? [event.localPosition[0], event.localPosition[2]] : snapWorldXZForActiveBuilding( @@ -202,7 +204,7 @@ export const wallStrategy = { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const x = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) const y = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) const z = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2]) @@ -266,7 +268,7 @@ export const wallStrategy = { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const snappedX = bypassSnap ? event.localPosition[0] : snapToHalf(event.localPosition[0]) const snappedY = bypassSnap ? event.localPosition[1] : snapToHalf(event.localPosition[1]) const snappedZ = bypassSnap ? event.localPosition[2] : snapToHalf(event.localPosition[2]) @@ -393,14 +395,14 @@ type RoofWallTarget = { * `wall-side` items mount on the outer surface, `wall` items center in * the wall thickness. * - * `shiftFree` mirrors the wall flow's Shift override (stubbed + * `freePlace` mirrors the wall flow's Alt override (stubbed * validators): the profile clamp is skipped, so the rect may overhang * the face edges — placement follows the snapped cursor as-is. */ function resolveRoofWallTarget( ctx: PlacementContext, event: RoofEvent, - shiftFree = false, + freePlace = false, ): RoofWallTarget | null { const attachTo = ctx.asset.attachTo if (attachTo !== 'wall' && attachTo !== 'wall-side') return null @@ -414,10 +416,10 @@ function resolveRoofWallTarget( const dims = getGridAlignedDimensions(rawDims, attachTo) const [width, height] = dims - const u = shiftFree ? hit.u : snapToHalf(hit.u) - const centerV = (shiftFree ? hit.v : snapToHalf(hit.v)) + height / 2 - const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height) - if (!fitted && !shiftFree) return null + const u = freePlace ? hit.u : snapToHalf(hit.u) + const centerV = (freePlace ? hit.v : snapToHalf(hit.v)) + height / 2 + const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height) + if (!fitted && !freePlace) return null const finalU = fitted?.u ?? u const finalV = fitted?.v ?? centerV @@ -483,8 +485,8 @@ export const roofWallStrategy = { * face. Returns null when the item doesn't wall-attach or the pointer * isn't over a placeable face. */ - enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null { - const target = resolveRoofWallTarget(ctx, event, shiftFree) + enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null { + const target = resolveRoofWallTarget(ctx, event, freePlace) if (!target) return null return { @@ -511,11 +513,11 @@ export const roofWallStrategy = { * segment transitions inside one roof never re-fire roof:enter) or to * no placeable face. */ - move(ctx: PlacementContext, event: RoofEvent, shiftFree = false): PlacementResult | null { + move(ctx: PlacementContext, event: RoofEvent, freePlace = false): PlacementResult | null { if (ctx.state.surface !== 'roof-wall') return null if (!ctx.draftItem) return null - const target = resolveRoofWallTarget(ctx, event, shiftFree) + const target = resolveRoofWallTarget(ctx, event, freePlace) if (!target) return null if (target.segment.id !== ctx.state.roofSegmentId) return null @@ -538,12 +540,12 @@ export const roofWallStrategy = { /** * Handle roof:click — commit placement on the segment wall face. */ - click(ctx: PlacementContext, _event: RoofEvent, shiftFree = false): CommitResult | null { + click(ctx: PlacementContext, _event: RoofEvent, freePlace = false): CommitResult | null { if (ctx.state.surface !== 'roof-wall') return null if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null - // Shift mirrors the wall flow's stubbed validators: skip profile-fit + // Alt mirrors the wall flow's stubbed validators: skip profile-fit // and overlap checks entirely. - if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null + if (!freePlace && !canPlaceOnRoofWall(ctx)) return null return { nodeUpdate: { @@ -615,7 +617,7 @@ export const ceilingStrategy = { // Ceiling items are stored in ceiling-local coordinates, so snapping must // use the ceiling hit's local position rather than world position. - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const x = bypassSnap ? event.localPosition[0] : snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) @@ -654,7 +656,7 @@ export const ceilingStrategy = { const rotY = ctx.draftItem.rotation?.[1] ?? 0 const swapDims = Math.abs(Math.sin(rotY)) > 0.9 - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const x = bypassSnap ? event.localPosition[0] : snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) @@ -771,7 +773,7 @@ export const itemSurfaceStrategy = { const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) if (surfaceHeight === null) return null - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const y = surfaceHeight @@ -823,7 +825,7 @@ export const itemSurfaceStrategy = { const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) if (surfaceHeight === null) return null - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const y = surfaceHeight @@ -924,7 +926,7 @@ export const shelfSurfaceStrategy = { const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) if (rowY === null) return null - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) @@ -969,7 +971,7 @@ export const shelfSurfaceStrategy = { const rowY = getShelfRowSurfaceY(shelfNode, localPos.y) if (rowY === null) return null - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const x = bypassSnap ? localPos.x : snapToGrid(localPos.x, ourDims[0]) const z = bypassSnap ? localPos.z : snapToGrid(localPos.z, ourDims[2]) const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z)) diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 4384da2a..9cf2ac8f 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -44,7 +44,7 @@ import { EDITOR_LAYER } from '../../../lib/constants' import { formatLinearMeasurement } from '../../../lib/measurements' import { sfxEmitter } from '../../../lib/sfx-bus' import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap' -import useEditor from '../../../store/use-editor' +import useEditor, { isMagneticSnapActive } from '../../../store/use-editor' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { createLineGeometry, @@ -221,7 +221,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea shelfId: null, }, ) - const shiftFreeRef = useRef(false) + const altFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) // Goes true the first time a 3D pointer event drives this coordinator. // The per-frame mesh-position lerp below is only useful for that path; @@ -441,7 +441,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea }) const getActiveValidators = () => - shiftFreeRef.current + altFreeRef.current ? { canPlaceOnFloor: () => ({ valid: true }), canPlaceOnWall: () => ({ valid: true }), @@ -450,7 +450,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea : validators const revalidate = (): boolean => { - const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators) + const placeable = altFreeRef.current || checkCanPlace(getContext(), validators) const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500 edgeMaterial.color.setHex(color) basePlaneMaterial.color.setHex(color) @@ -610,8 +610,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Floor grab-offset: the item tracks the grabbed point instead of snapping // its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid // (`event.position`) on its default path and only reads `event.localPosition` - // under Shift, so both frames must carry the offset; the world point is - // derived from the corrected local one so the two stay consistent. + // under Alt (free place), so both frames must carry the offset; the world + // point is derived from the corrected local one so the two stay consistent. const applyFloorGrabOffset = (event: GridEvent): GridEvent => { if (relativeFloorStart === null) return event const rawX = event.localPosition[0] @@ -773,12 +773,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // item's edge, snap and publish a guide. The guide connects to the // nearest real corner of the candidate (resolver tie-break), so the dot // always sits on an actual point. The delta is applied to BOTH the grid - // and cursor positions below. Alt bypasses alignment; Shift bypasses all snap. + // and cursor positions below. Alt (free place) bypasses all snap; the + // active snapping mode governs whether alignment runs at all ('off' / + // 'angles' disable magnetic alignment, matching the wall/fence flow). const draft = draftNode.current let alignX = 0 let alignZ = 0 - const bypassSnap = floorEvent.nativeEvent?.shiftKey === true - const bypassAlign = floorEvent.nativeEvent?.altKey === true || bypassSnap + const freePlace = floorEvent.nativeEvent?.altKey === true + const bypassAlign = freePlace || !isMagneticSnapActive() if (!bypassAlign && draft) { alignmentCandidates ??= collectAlignmentAnchors( useScene.getState().nodes, @@ -812,7 +814,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Play snap sound when grid position changes if ( - !bypassSnap && + !freePlace && previousGridPos && (gridPos[0] !== previousGridPos[0] || gridPos[2] !== previousGridPos[2]) ) { @@ -997,7 +999,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.z !== result.gridPosition[2] // Play snap sound when grid position changes - if (event.nativeEvent?.shiftKey !== true && posChanged) { + if (event.nativeEvent?.altKey !== true && posChanged) { sfxEmitter.emit('sfx:grid-snap') } @@ -1121,7 +1123,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // re-enters whenever the strategy reports a segment change. const enterRoofWall = (event: RoofEvent): boolean => { - const result = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current) + const result = roofWallStrategy.enter(getContext(), event, altFreeRef.current) if (!result) return false event.stopPropagation() @@ -1152,7 +1154,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current) + const result = roofWallStrategy.move(ctx, event, altFreeRef.current) if (!result) { // Different segment under the pointer (or no placeable face) — // try a fresh enter; a null resolve leaves the draft where it is. @@ -1167,7 +1169,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.z !== result.gridPosition[2] - if (!shiftFreeRef.current && posChanged) { + if (!altFreeRef.current && posChanged) { sfxEmitter.emit('sfx:grid-snap') } @@ -1210,7 +1212,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onRoofWallClick = (event: RoofEvent) => { - const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current) + const result = roofWallStrategy.click(getContext(), event, altFreeRef.current) if (!result) return event.stopPropagation() @@ -1220,7 +1222,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { - const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current) + const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current) if (enterResult) { applyTransition(enterResult) } else { @@ -1261,7 +1263,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea event.position[1], event.position[2], ) - const bypassSnap = event.nativeEvent?.shiftKey === true + const bypassSnap = event.nativeEvent?.altKey === true const wx = bypassSnap ? buildingLocalPoint.x : Math.round(buildingLocalPoint.x * 2) / 2 const wz = bypassSnap ? buildingLocalPoint.z : Math.round(buildingLocalPoint.z * 2) / 2 const floorPos: [number, number, number] = [wx, 0, wz] @@ -1598,7 +1600,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.y !== result.gridPosition[1] || gridPosition.current.z !== result.gridPosition[2] - if (event.nativeEvent?.shiftKey !== true && posChanged) { + if (event.nativeEvent?.altKey !== true && posChanged) { sfxEmitter.emit('sfx:grid-snap') } @@ -1793,8 +1795,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // items (use-keyboard.ts) so the ghost/duplicate rotates the same way. const ROTATION_STEP = Math.PI / 4 const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftFreeRef.current = true + if (event.key === 'Alt') { + altFreeRef.current = true revalidate() return } @@ -1908,8 +1910,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - shiftFreeRef.current = false + if (event.key === 'Alt') { + altFreeRef.current = false revalidate() } } @@ -1997,6 +1999,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) + + // A floor placement commits at the tracked floor cursor (`gridPosition`), + // which keeps following the floor even when the click ray lands on a wall + // (grid:move uses a separate ground-plane raycast). Without this, a commit + // click whose ray hits a wall fires only `wall:click` — whose handler + // declines for a floor item — and the click is silently eaten (the user + // has to click again until the ray happens to clear the wall). Route every + // surface click to the floor commit too; `floorStrategy.click` guards on + // `surface === 'floor'` (and a non-attach draft), so it no-ops while the + // draft is actually resting on that surface. + const commitFloorOnSurfaceClick = (event: { stopPropagation: () => void }) => { + if (placementState.current.surface !== 'floor') return + onGridClick(event as unknown as GridEvent) + } + emitter.on('wall:click', commitFloorOnSurfaceClick as never) + emitter.on('item:click', commitFloorOnSurfaceClick as never) + emitter.on('ceiling:click', commitFloorOnSurfaceClick as never) + emitter.on('roof:click', commitFloorOnSurfaceClick as never) + emitter.on('shelf:click', commitFloorOnSurfaceClick as never) if (dragMode) window.addEventListener('pointerup', onReleaseCommit) return () => { @@ -2032,6 +2053,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) + emitter.off('wall:click', commitFloorOnSurfaceClick as never) + emitter.off('item:click', commitFloorOnSurfaceClick as never) + emitter.off('ceiling:click', commitFloorOnSurfaceClick as never) + emitter.off('roof:click', commitFloorOnSurfaceClick as never) + emitter.off('shelf:click', commitFloorOnSurfaceClick as never) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -2114,7 +2140,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Restore the draft mesh's raycast when the coordinator unmounts (tool change). useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast]) - useFrame((_, delta) => { + useFrame(() => { if (!asset) { reconcileDraftRaycast(null) return @@ -2145,12 +2171,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea mesh.visible = true if (placementState.current.surface === 'floor') { - const distance = mesh.position.distanceToSquared(gridPosition.current) - if (distance > 1) { - mesh.position.copy(gridPosition.current) - } else { - mesh.position.lerp(gridPosition.current, delta * 20) - } + // Track the cursor 1:1. An earlier per-frame lerp (delta*20) made an + // active move visibly trail the cursor and — combined with React + // re-renders momentarily pulling the mesh back toward its committed + // position — read as a laggy snap-back on every move. Copying each frame + // locks placement/move to the cursor and overrides any stray reset + // within a single frame, so it feels precise instead of dragging. + mesh.position.copy(gridPosition.current) // Adjust Y for slab elevation (floor items on top of slabs) if (!asset.attachTo) { diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index d68e02f4..dc61471d 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -30,7 +30,8 @@ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' +import { resolveSnapFlags } from '../../../lib/snapping-mode' +import useEditor, { isMagneticSnapActive } from '../../../store/use-editor' import { swallowNextClick } from '../../editor/node-arrow-handles' import { CursorSphere } from '../shared/cursor-sphere' import { DragBoundingBox } from '../shared/drag-bounding-box' @@ -41,7 +42,9 @@ import { PlacementBox } from '../shared/placement-box' /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */ const snapToGridStep = (value: number) => { - const step = useEditor.getState().gridSnapStep + const state = useEditor.getState() + if (!resolveSnapFlags(state.snappingMode).grid) return value + const step = state.gridSnapStep return Math.round(value / step) * step } @@ -244,10 +247,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // a register collar drops onto a duct run end. Reads `def.ports` through // the core registry, so it stays layer-clean (no @pascal-app/nodes import). const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null - // Mirrors of `valid` / Shift for the event handlers inside the effect, which + // Mirrors of `valid` / Alt for the event handlers inside the effect, which // can't read React state without stale closures. const validRef = useRef(true) - const shiftRef = useRef(false) + const altRef = useRef(false) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -259,7 +262,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { dragAnchorRef.current = null hasMovedRef.current = false rotationRef.current = originalRotationY - shiftRef.current = false + altRef.current = false validRef.current = true // Re-sync the box transform to the (possibly new) node. `node` changes // without this component remounting whenever a positioned preset re-arms a @@ -335,12 +338,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) // Re-run the floor-collision check at the live cursor + rotation and push - // the result to the box colour. Shift forces a valid (green) override so - // the user can drop on top of an existing item on purpose. Only shelves - // show the box, so this no-ops for every other movable kind. + // the result to the box colour. Alt (free place) forces a valid (green) + // override so the user can drop on top of an existing item on purpose. Only + // shelves show the box, so this no-ops for every other movable kind. const recomputeValidity = () => { if (!boxDimensions) return - if (shiftRef.current) { + if (altRef.current) { validRef.current = true setValid(true) return @@ -417,7 +420,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { original: [originalPosition[0], originalPosition[2]], anchor: dragAnchorRef.current, mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', - snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep, + snap: event.nativeEvent?.altKey === true ? (value) => value : snapToGridStep, }) dragAnchorRef.current = resolved.anchor let [x, z] = resolved.point @@ -426,8 +429,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // moving item's edge lines up (on X or Z) with another item's edge, // snap and publish a guide. The guide connects to the nearest real // corner of the candidate (resolver tie-break), so the dot always sits - // on an actual point. Alt bypasses alignment; Shift bypasses all snap. - const bypass = event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true + // on an actual point. Alt (free place) bypasses all snap; the active + // snapping mode governs whether magnetic alignment runs at all. + const freePlace = event.nativeEvent?.altKey === true + const bypass = freePlace || !isMagneticSnapActive() if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignment({ moving: movingFootprintAnchors(node, x, z, rotationRef.current), @@ -488,7 +493,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { previewConnectivity(position, rotationRef.current) const prev = previousSnapRef.current - if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) { + if (!freePlace && (!prev || prev[0] !== x || prev[1] !== z)) { sfxEmitter.emit('sfx:grid-snap') previousSnapRef.current = [x, z] } @@ -524,9 +529,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // deliberate drop. Prevents preset re-arm from double-placing. if (!hasMovedRef.current) return // Refuse a drop on an invalid (red) footprint, matching the GLB item - // tool — unless Shift is held to force placement. Other kinds carry no - // validity box (`validRef` stays true), so they're never blocked. - if (!validRef.current && !shiftRef.current) return + // tool — unless Alt (free place) is held to force placement. Other kinds + // carry no validity box (`validRef` stays true), so they're never blocked. + if (!validRef.current && !altRef.current) return const position: [number, number, number] = [...lastCursorRef.current] const rotation = toCommitRotation(rotationRef.current) @@ -624,10 +629,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // item placement keys (and the "Rotate" hints the move HUD shows). Applied // imperatively + mirrored to the live transform; committed on drop. const onKeyDown = (e: KeyboardEvent) => { - // Hold Shift to force placement on an invalid (red) footprint, matching - // the GLB item tool. Recolour the box to green while held. - if (e.key === 'Shift') { - shiftRef.current = true + // Hold Alt (free place) to force placement on an invalid (red) footprint, + // matching the GLB item tool. Recolour the box to green while held. + if (e.key === 'Alt') { + altRef.current = true recomputeValidity() return } @@ -659,8 +664,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { recomputeValidity() } const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') { - shiftRef.current = false + if (e.key === 'Alt') { + altRef.current = false recomputeValidity() } } diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 05817dd5..bf5a1979 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -4,6 +4,7 @@ import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' import { Box3, type Camera, type Object3D, Vector3 } from 'three' import useEditor from '../../../store/use-editor' +import useInteractionScope from '../../../store/use-interaction-scope' import { clearBoxSelectHandled, isBoxSelectPointerSuppressed, @@ -191,6 +192,12 @@ const ScreenRectangleSelectTool: React.FC = () => { const currentClientXRef = useRef(0) const currentClientYRef = useRef(0) const spaceDownRef = useRef(false) + // rAF throttle for the expensive marquee preview pass. pointermove can fire + // several times per animation frame; the per-node AABB projection in + // `collectNodeIdsInScreenRect` only needs to run once per frame. We stash the + // latest clamped rect and process it inside the rAF callback. + const previewRafRef = useRef(null) + const pendingPreviewRectRef = useRef(null) const syncPreviewSelectedIds = useCallback( (nextIds: string[]) => { @@ -206,6 +213,11 @@ const ScreenRectangleSelectTool: React.FC = () => { pointerDownRef.current = false isDraggingRef.current = false pointerIdRef.current = null + if (previewRafRef.current !== null) { + cancelAnimationFrame(previewRafRef.current) + previewRafRef.current = null + } + pendingPreviewRectRef.current = null hideScreenRectangleSelectionElement(elementRef.current) syncPreviewSelectedIds([]) @@ -213,6 +225,7 @@ const ScreenRectangleSelectTool: React.FC = () => { useViewer.getState().setInputDragging(false) ownsInputDraggingRef.current = false } + useInteractionScope.getState().endIf((s) => s.kind === 'box-select') }, [syncPreviewSelectedIds]) useEffect(() => { @@ -263,6 +276,14 @@ const ScreenRectangleSelectTool: React.FC = () => { useEffect(() => { const canvas = gl.domElement + const flushPreview = () => { + previewRafRef.current = null + const rect = pendingPreviewRectRef.current + if (!rect) return + pendingPreviewRectRef.current = null + syncPreviewSelectedIds(collectNodeIdsInScreenRect(rect, camera, canvas)) + } + const updateDrag = (event: PointerEvent) => { if (!pointerDownRef.current) return if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return @@ -291,6 +312,7 @@ const ScreenRectangleSelectTool: React.FC = () => { isDraggingRef.current = true ownsInputDraggingRef.current = true useViewer.getState().setInputDragging(true) + useInteractionScope.getState().begin({ kind: 'box-select' }) markBoxSelectHandled() try { canvas.setPointerCapture(event.pointerId) @@ -311,13 +333,22 @@ const ScreenRectangleSelectTool: React.FC = () => { screenRectFromDomRect(canvas.getBoundingClientRect()), ) if (!clampedRect) { + if (previewRafRef.current !== null) { + cancelAnimationFrame(previewRafRef.current) + previewRafRef.current = null + } + pendingPreviewRectRef.current = null hideScreenRectangleSelectionElement(elementRef.current) syncPreviewSelectedIds([]) return } updateScreenRectangleSelectionElement(elementRef.current!, clampedRect) - syncPreviewSelectedIds(collectNodeIdsInScreenRect(clampedRect, camera, canvas)) + // Coalesce the per-node AABB projection to one run per animation frame. + pendingPreviewRectRef.current = clampedRect + if (previewRafRef.current === null) { + previewRafRef.current = requestAnimationFrame(flushPreview) + } } const finishDrag = (event: PointerEvent) => { diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 95df3d10..957fe815 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -56,6 +56,7 @@ export const ToolManager: React.FC = () => { const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) const movingNode = useEditor((state) => state.movingNode) + const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin) const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const curvingWall = useEditor((state) => state.curvingWall) @@ -134,6 +135,16 @@ export const ToolManager: React.FC = () => { // Show build tools when in build mode const showBuildTool = mode === 'build' && tool !== null + // A move initiated from the 2D floor-plan (orange move-dot) is owned end-to- + // end by `FloorplanRegistryMoveOverlay`, which marks the origin `'2d'` at + // dot-down. Mounting the 3D affordance mover alongside it would adopt the + // same node and, on its unmount, restore the adopt-time position — snapping + // the committed 2D move back to its start. Gate the 3D mover off for 2D moves + // (the scene writes the overlay makes still mirror into the 3D view). A + // 3D-initiated move leaves the origin null until its own commit, so this only + // suppresses the 3D tool for genuinely 2D-owned moves. + const showMover = movingNode != null && movingNodeOrigin !== '2d' + // Registry-first: if the active tool's kind has a NodeDefinition with a // tool contribution, the registry-driven tool takes over. const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null @@ -163,7 +174,7 @@ export const ToolManager: React.FC = () => { <> {/* World-space tools: site boundary and building movement operate in world coordinates */} {showSiteBoundaryEditor && } - {movingNode?.type === 'building' && ( + {showMover && movingNode?.type === 'building' && ( )} @@ -259,7 +270,7 @@ export const ToolManager: React.FC = () => { ) : null })()} - {movingNode && movingNode.type !== 'building' && ( + {showMover && movingNode.type !== 'building' && ( +
{keys.map((key, index) => ( -
+
{index > 0 ? + : null} - +
))}
) } -export function ContextualHelperPanel({ hints }: { hints: ContextualShortcutHint[] }) { - if (hints.length === 0) return null +const SNAPPING_MODE_ICONS = { + grid: 'lucide:grid-2x2', + lines: 'lucide:magnet', + angles: 'lucide:triangle', + off: 'lucide:ban', +} as const + +const SNAPPING_MODE_LABELS = { + grid: 'Grid', + lines: 'Lines', + angles: 'Angles', + off: 'Off', +} as const + +const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] + +function nextGridSnapStep(step: GridSnapStep): GridSnapStep { + const index = GRID_SNAP_STEPS.indexOf(step) + return GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]! +} + +// Interactive chip rows: the active interaction's own snapping controls. The +// surrounding stack is `pointer-events-none` (passive key hints), so these +// pills carve out `pointer-events-auto` to stay clickable. +function SnappingChips() { + const snappingMode = useEditor((s) => s.snappingMode) + const cycleSnappingMode = useEditor((s) => s.cycleSnappingMode) + const gridSnapStep = useEditor((s) => s.gridSnapStep) + const setGridSnapStep = useEditor((s) => s.setGridSnapStep) + + const gridActive = resolveSnapFlags(snappingMode).grid return ( -
+ <> + + + + + Snapping mode — click or press Shift to cycle + + + {gridActive ? ( + + + + + Grid step — click or tap Ctrl to cycle + + ) : null} + + ) +} + +export function ContextualHelperPanel({ + hints, + showSnapping = false, +}: { + hints: ContextualShortcutHint[] + showSnapping?: boolean +}) { + if (hints.length === 0 && !showSnapping) return null + + return ( +
+ {showSnapping ? : null} {hints.map((hint) => (
+ {hint.label} - {hint.label}
))}
diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 8107d8ce..8013b449 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -10,7 +10,11 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { useIsMobile } from '../../../hooks/use-mobile' -import { resolveSelectModeHelpHints } from '../../../lib/contextual-help' +import { + ROTATE_HANDLE_DRAG_LABEL, + resolveRotateHandleHelpHints, + resolveSelectModeHelpHints, +} from '../../../lib/contextual-help' import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation' import useEditor from '../../../store/use-editor' import { BuildingHelper } from './building-helper' @@ -62,6 +66,7 @@ export function HelperManager() { const mode = useEditor((s) => s.mode) const tool = useEditor((s) => s.tool) const movingNode = useEditor((state) => state.movingNode) + const activeHandleDrag = useEditor((state) => state.activeHandleDrag) const selectedIds = useViewer((s) => s.selection.selectedIds) const isMobile = useIsMobile() const modifiers = useActiveModifierKeys() @@ -87,9 +92,16 @@ export function HelperManager() { // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. if (isMobile) return null + // Rotating a node via its in-world gizmo: advertise Shift = free rotation, + // the same angle-step bypass wall drafting exposes. Takes priority over the + // idle select-mode hints since a handle drag is the active interaction. + if (activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL) { + return + } + if (movingNode) { if (movingNode.type === 'building') return - return + return } if (mode === 'material-paint') { diff --git a/packages/editor/src/components/ui/helpers/item-helper.tsx b/packages/editor/src/components/ui/helpers/item-helper.tsx index d73b0b91..c1c75fae 100644 --- a/packages/editor/src/components/ui/helpers/item-helper.tsx +++ b/packages/editor/src/components/ui/helpers/item-helper.tsx @@ -2,21 +2,18 @@ import { ContextualHelperPanel } from './contextual-helper-panel' interface ItemHelperProps { showEsc?: boolean - shiftPressed?: boolean } -export function ItemHelper({ showEsc, shiftPressed = false }: ItemHelperProps) { +export function ItemHelper({ showEsc }: ItemHelperProps) { return ( diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index a31946ae..8ed6d94f 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -20,12 +20,19 @@ export function RegisteredToolHelper({ if (hints.length === 0) return null return ( ({ - keys: [hint.key], - label: - shiftPressed && hint.key === 'Shift' ? 'Guided constraints bypassed' : hint.label, - active: shiftPressed && hint.key === 'Shift', - }))} + showSnapping + hints={hints.map((hint) => { + // Shift is a per-kind bypass for item / opening / zone / duct placement + // ("Free place", "Free angle", …) — those hints flip to a bypassed + // state while held. For wall / fence, Shift now cycles the snapping + // mode (no hold-to-bypass), so it must NOT show the bypass treatment. + const isBypassHint = hint.key === 'Shift' && hint.label !== 'Cycle snapping mode' + return { + keys: [hint.key], + label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label, + active: shiftPressed && isBypassHint, + } + })} /> ) } diff --git a/packages/editor/src/components/ui/helpers/roof-helper.tsx b/packages/editor/src/components/ui/helpers/roof-helper.tsx index 07111a87..ad45108a 100644 --- a/packages/editor/src/components/ui/helpers/roof-helper.tsx +++ b/packages/editor/src/components/ui/helpers/roof-helper.tsx @@ -3,6 +3,7 @@ import { ContextualHelperPanel } from './contextual-helper-panel' export function RoofHelper({ shiftPressed = false }: { shiftPressed?: boolean }) { return ( { useFrame(() => { const { levelId, zoneId } = useViewer.getState().selection const structureLayer = useEditor.getState().structureLayer const nodes = useScene.getState().nodes + // During any active interaction zone labels step back entirely (Sims-light). + const zoneLabelsHidden = + resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden' sceneRegistry.byType.zone!.forEach((id) => { const obj = sceneRegistry.nodes.get(id) @@ -35,7 +40,7 @@ export const ViewerZoneSystem = () => { }) // Labels: always visible on the current level (regardless of mode or zone selection) - const showLabel = !!levelId && isOnSelectedLevel + const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel const targetOpacity = showLabel ? '1' : '0' const labelEl = document.getElementById(`${id}-label`) if (labelEl && labelEl.style.opacity !== targetOpacity) { diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index b5f170ab..50c5347d 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -40,12 +40,56 @@ export const useKeyboard = ({ return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window') } + // Shift cycles the snapping mode while a snapping-mode-governed draft is + // armed: wall / fence build, item placement (build + item tool), and any + // active node move (`movingNode` — covers item 3D moves plus the generic + // registry move for shelf / spawn / column / stair). For items, free place + // moved to Alt, so Shift is free to cycle here too. Elsewhere Shift keeps + // its existing meaning — multi-select in plain select mode (no movingNode), + // free-place bypass during opening / zone placement — so this predicate + // must NOT fire for those. Door / window moves still use Shift for free + // place (out of this overhaul's scope), so they're excluded. + const isSnappingCycleContext = () => { + const ed = useEditor.getState() + const moving = ed.movingNode + if (moving != null) return moving.type !== 'door' && moving.type !== 'window' + return ( + ed.mode === 'build' && (ed.tool === 'wall' || ed.tool === 'fence' || ed.tool === 'item') + ) + } + + // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in + // between) cycles the grid step — same context as the Shift snapping-mode + // cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone + // and is cleared the instant any other key fires, so chords like Ctrl+Z / + // Ctrl+C never cycle. + let ctrlTapClean = false + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Control' || e.key === 'Meta') { + // Only a fresh, modifier-free press starts a clean-tap candidate; + // ignore key-repeat and presses already part of a combo. + ctrlTapClean = !e.repeat && !e.shiftKey && !e.altKey + } else { + // Any non-modifier key (or a modifier combined with Ctrl/Meta) breaks + // the clean tap. + ctrlTapClean = false + } + // Don't handle shortcuts if user is typing in an input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return } + if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) { + // Cycle the global snapping mode (grid → lines → angles → off). + // `'off'` is the snap bypass now, so Shift no longer holds-to-bypass. + e.preventDefault() + useEditor.getState().cycleSnappingMode() + sfxEmitter.emit('sfx:grid-snap') + return + } + if (e.key === 'Escape') { e.preventDefault() _toolCancelConsumed = false @@ -91,6 +135,9 @@ export const useKeyboard = ({ e.preventDefault() useEditor.getState().setPhase('furnish') useEditor.getState().setMode('build') + // Set the item tool explicitly so the active tool never inherits a + // stale tool from a prior build session. + useEditor.getState().setTool('item') useEditor.getState().setActiveSidebarPanel('items') } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { if (isVersionPreviewMode) return @@ -98,6 +145,8 @@ export const useKeyboard = ({ useEditor.getState().setPhase('structure') useEditor.getState().setStructureLayer('zones') useEditor.getState().setMode('build') + // Set the zone tool explicitly so it never inherits a stale tool. + useEditor.getState().setTool('zone') } if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { e.preventDefault() @@ -109,6 +158,9 @@ export const useKeyboard = ({ useEditor.getState().setPhase('structure') useEditor.getState().setStructureLayer('elements') useEditor.getState().setMode('build') + // Set the wall tool explicitly so B never inherits a stale tool + // (e.g. fence) left over from a prior build session. + useEditor.getState().setTool('wall') } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) { if (isVersionPreviewMode) return e.preventDefault() @@ -346,8 +398,28 @@ export const useKeyboard = ({ } } } + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key !== 'Control' && e.key !== 'Meta') return + const wasClean = ctrlTapClean + ctrlTapClean = false + if (!wasClean) return + // Same scope as the Shift snapping-mode cycle: wall / fence build only, + // and never while typing in an input. + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return + } + if (!isSnappingCycleContext()) return + // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05). + useEditor.getState().cycleGridSnapStep() + sfxEmitter.emit('sfx:grid-snap') + } + window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } }, [disabled, isVersionPreviewMode]) return null diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 1ef26d77..dad569cc 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -327,7 +327,7 @@ export type { ViewMode, WorkspaceMode, } from './store/use-editor' -export { default as useEditor } from './store/use-editor' +export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from './store/use-editor' export { default as useOpeningGuides, type OpeningGuide3D, diff --git a/packages/editor/src/lib/contextual-help.test.ts b/packages/editor/src/lib/contextual-help.test.ts index 6bb40875..5da5bd25 100644 --- a/packages/editor/src/lib/contextual-help.test.ts +++ b/packages/editor/src/lib/contextual-help.test.ts @@ -49,7 +49,10 @@ describe('resolveSelectModeHelpHints', () => { keys: ['Cmd/Ctrl', 'Right click'], label: 'Drag left or right to rotate selected object', }) - expect(hints).toContainEqual({ + // The Shift bypass hint is gated to the in-progress direct-move gesture + // (Cmd/Ctrl held); on an idle selection it must not appear (Shift there + // means multi-select, not bypass). + expect(hints).not.toContainEqual({ keys: ['Shift'], label: 'Hold to bypass snaps and angle steps', active: false, diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index 3648a8c9..8a741198 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -4,6 +4,25 @@ export type ContextualShortcutHint = { active?: boolean } +// `activeHandleDrag.label` value a rotate gizmo sets while dragging, so the +// contextual HUD can surface the Shift = free-rotation toggle for the duration +// (mirrors how wall drafting advertises Shift). Distinct from resize handles, +// which route their own measurement label here. +export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle' + +// Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step +// (free rotation), the same toggle wall drafting exposes. `active` lights the +// pill while Shift is held. +export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualShortcutHint[] { + return [ + { + keys: [SHIFT_KEY], + label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely', + active: shiftPressed, + }, + ] +} + export type SelectModeHelpContext = { selectedCount: number hasMovableSelection: boolean @@ -79,11 +98,16 @@ export function resolveSelectModeHelpHints({ } } - hints.push({ - keys: [SHIFT_KEY], - label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps', - active: shiftPressed, - }) + // The Shift bypass only applies to an in-progress direct move/rotate + // (the Cmd/Ctrl-drag gesture), so only surface it while that modifier is + // engaged — not on an idle selection, where Shift means multi-select. + if (commandPressed && (hasMovableSelection || hasRotatableSelection)) { + hints.push({ + keys: [SHIFT_KEY], + label: shiftPressed ? 'Guided constraints bypassed' : 'Hold to bypass snaps and angle steps', + active: shiftPressed, + }) + } if (!commandPressed) { hints.push({ diff --git a/packages/editor/src/lib/floorplan/geometry.ts b/packages/editor/src/lib/floorplan/geometry.ts index f334570f..3fe28e8e 100644 --- a/packages/editor/src/lib/floorplan/geometry.ts +++ b/packages/editor/src/lib/floorplan/geometry.ts @@ -11,6 +11,46 @@ export function rotatePlanVector(x: number, y: number, rotation: number): [numbe return [x * cos + y * sin, -x * sin + y * cos] } +// Converts a world X/Z point into the floor-plan-local (building-local) +// frame used by the SVG scene `` and every stored node position. The +// inverse of `floorplanLocalToWorldPoint`. Shared so the floor-plan panel +// and the 2D move overlay resolve the same frame — feeding a world-space +// `original` into a local-space cursor solver lands the drop off by the +// building's world X/Z (worse for an off-origin building). +export function worldToFloorplanLocalPoint( + worldX: number, + worldZ: number, + buildingPosition: readonly [number, number, number], + buildingRotationY: number, +): Point2D { + const dx = worldX - buildingPosition[0] + const dz = worldZ - buildingPosition[2] + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + + return { + x: dx * cos - dz * sin, + y: dx * sin + dz * cos, + } +} + +// Inverse of `worldToFloorplanLocalPoint`: floor-plan-local X/Y → world X/Z. +export function floorplanLocalToWorldPoint( + point: Point2D | [number, number], + buildingPosition: readonly [number, number, number], + buildingRotationY: number, +): { x: number; z: number } { + const localX = Array.isArray(point) ? point[0] : point.x + const localY = Array.isArray(point) ? point[1] : point.y + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + + return { + x: buildingPosition[0] + localX * cos + localY * sin, + z: buildingPosition[2] - localX * sin + localY * cos, + } +} + export function getRotatedRectanglePolygon( center: Point2D, width: number, diff --git a/packages/editor/src/lib/floorplan/index.ts b/packages/editor/src/lib/floorplan/index.ts index 4aa913a5..a7c2fb3f 100644 --- a/packages/editor/src/lib/floorplan/index.ts +++ b/packages/editor/src/lib/floorplan/index.ts @@ -8,6 +8,7 @@ export { export { clampPlanValue, doesPolygonIntersectSelectionBounds, + floorplanLocalToWorldPoint, getDistanceToWallSegment, getFloorplanSelectionBounds, getPlanPointDistance, @@ -20,6 +21,7 @@ export { movePlanPointTowards, pointMatchesWallPlanPoint, rotatePlanVector, + worldToFloorplanLocalPoint, } from './geometry' export { buildFloorplanItemEntry, diff --git a/packages/editor/src/lib/interaction/hot-set.test.ts b/packages/editor/src/lib/interaction/hot-set.test.ts new file mode 100644 index 00000000..016cd98f --- /dev/null +++ b/packages/editor/src/lib/interaction/hot-set.test.ts @@ -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) + }) +}) diff --git a/packages/editor/src/lib/interaction/hot-set.ts b/packages/editor/src/lib/interaction/hot-set.ts new file mode 100644 index 00000000..2ecf4b37 --- /dev/null +++ b/packages/editor/src/lib/interaction/hot-set.ts @@ -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 +} diff --git a/packages/editor/src/lib/interaction/overlay-policy.test.ts b/packages/editor/src/lib/interaction/overlay-policy.test.ts new file mode 100644 index 00000000..7d0166ef --- /dev/null +++ b/packages/editor/src/lib/interaction/overlay-policy.test.ts @@ -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) + } + }) +}) diff --git a/packages/editor/src/lib/interaction/overlay-policy.ts b/packages/editor/src/lib/interaction/overlay-policy.ts new file mode 100644 index 00000000..d3dfd39d --- /dev/null +++ b/packages/editor/src/lib/interaction/overlay-policy.ts @@ -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 +} diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts new file mode 100644 index 00000000..24df6ae8 --- /dev/null +++ b/packages/editor/src/lib/interaction/scope.ts @@ -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 + +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' +} diff --git a/packages/editor/src/lib/planar-cursor-placement.test.ts b/packages/editor/src/lib/planar-cursor-placement.test.ts index 8308bb50..a665ca0a 100644 --- a/packages/editor/src/lib/planar-cursor-placement.test.ts +++ b/packages/editor/src/lib/planar-cursor-placement.test.ts @@ -40,4 +40,61 @@ describe('resolvePlanarCursorPosition', () => { expect(moved.point).toEqual([11, 19]) expect(moved.anchor).toEqual([4.1, 6.1]) }) + + // Track B regression: "off-slab cursor, on-slab footprint stays at center". + // When the gizmo is grabbed off the footprint center (e.g. near a slab edge), + // the resolved center must track original + cursorDelta and be independent of + // the initial grab offset — so a footprint fully inside a slab cannot be + // pushed off the edge just because the cursor sample landed off-center. + test('relative mode cancels the off-center gizmo grab offset so the committed center is offset-independent', () => { + const original: [number, number] = [2, 2] + const firstSample: [number, number] = [2.3, 2.3] + const cursor: [number, number] = [3.1, 1.6] + + const start = resolvePlanarCursorPosition({ + cursor: firstSample, + original, + anchor: null, + mode: 'relative', + }) + + // First sample absorbs the off-center grab: the footprint stays put. + expect(start.point).toEqual(original) + expect(start.anchor).toEqual(firstSample) + + const moved = resolvePlanarCursorPosition({ + cursor, + original, + anchor: start.anchor, + mode: 'relative', + }) + + // Committed center = original + (cursor - firstSample), i.e. the gizmo + // offset is cancelled regardless of where on the footprint it was grabbed. + const expected: [number, number] = [ + original[0] + (cursor[0] - firstSample[0]), + original[1] + (cursor[1] - firstSample[1]), + ] + expect(moved.point[0]).toBeCloseTo(expected[0]) + expect(moved.point[1]).toBeCloseTo(expected[1]) + + // The result must not depend on the absolute grab offset: grabbing the same + // footprint dead-center and moving by the same delta yields the same center. + const centerStart = resolvePlanarCursorPosition({ + cursor: original, + original, + anchor: null, + mode: 'relative', + }) + const delta: [number, number] = [cursor[0] - firstSample[0], cursor[1] - firstSample[1]] + const centerMoved = resolvePlanarCursorPosition({ + cursor: [original[0] + delta[0], original[1] + delta[1]], + original, + anchor: centerStart.anchor, + mode: 'relative', + }) + + expect(centerMoved.point[0]).toBeCloseTo(moved.point[0]) + expect(centerMoved.point[1]).toBeCloseTo(moved.point[1]) + }) }) diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts new file mode 100644 index 00000000..3c9499bc --- /dev/null +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -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) + }) +}) diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts new file mode 100644 index 00000000..257c5867 --- /dev/null +++ b/packages/editor/src/lib/snapping-mode.ts @@ -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 = { + 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 +} diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts index 4d48a6b1..67d93011 100644 --- a/packages/editor/src/lib/surface-plan-snap.ts +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -19,7 +19,7 @@ import { type WallSnapRadii, } from '../components/tools/wall/wall-drafting' import useAlignmentGuides from '../store/use-alignment-guides' -import useEditor from '../store/use-editor' +import { isMagneticSnapActive } from '../store/use-editor' import useWallSnapIndicator from '../store/use-wall-snap-indicator' const SURFACE_SNAP_MOVING_ID = '__surface_snap__' @@ -181,7 +181,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac const nodes = input.nodes ?? useScene.getState().nodes const walls = getLevelWalls(nodes, input.levelId, input.walls) const fallbackPoint = input.fallbackPoint - const magnetic = input.magnetic ?? useEditor.getState().magneticSnap + const magnetic = input.magnetic ?? isMagneticSnapActive() const wallSnap = snapWallDraftPointDetailed({ point: input.rawPoint, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 64c28b31..5e3fd497 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -40,6 +40,14 @@ import { resolvePaintTargetFromSelection, type SingleSurfaceMaterialRole, } from '../lib/material-paint' +import { + DEFAULT_SNAPPING_MODE, + nextSnappingMode, + resolveSnapFlags, + SNAPPING_MODES, + type SnappingMode, +} from '../lib/snapping-mode' +import useInteractionScope from './use-interaction-scope' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'ai' const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 @@ -374,11 +382,20 @@ type EditorState = { setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void gridSnapStep: GridSnapStep setGridSnapStep: (step: GridSnapStep) => void + // Cycles the grid step through GRID_SNAP_STEPS (0.5 → 0.25 → 0.1 → 0.05 → + // 0.5) and returns the new value. Bound to the measurement-step shortcut. + cycleGridSnapStep: () => GridSnapStep // Magnetic snapping while drafting — snaps wall endpoints onto existing // wall corners / wall bodies (the "magnetic" beacon). Independent of grid // snap. On by default; toggled from the Display menu. magneticSnap: boolean setMagneticSnap: (enabled: boolean) => void + // Global, user-cyclable snapping mode. Maps onto `gridSnapStep` (grid) and + // `magneticSnap` via `resolveSnapFlags`. Default `'grid'` reproduces the + // historical behaviour (grid + magnetic on). + snappingMode: SnappingMode + setSnappingMode: (mode: SnappingMode) => void + cycleSnappingMode: () => SnappingMode showReferenceFloor: boolean toggleReferenceFloor: () => void setShowReferenceFloor: (show: boolean) => void @@ -427,6 +444,7 @@ type PersistedEditorLayoutState = Pick< | 'floorplanSelectionTool' | 'gridSnapStep' | 'magneticSnap' + | 'snappingMode' | 'showReferenceFloor' | 'referenceFloorOffset' | 'referenceFloorOpacity' @@ -450,6 +468,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = floorplanSelectionTool: 'click', gridSnapStep: 0.5, magneticSnap: true, + snappingMode: DEFAULT_SNAPPING_MODE, showReferenceFloor: false, referenceFloorOffset: 1, referenceFloorOpacity: 0.35, @@ -568,6 +587,9 @@ function normalizePersistedEditorLayoutState( : DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, // Default on: only an explicit persisted `false` disables it. magneticSnap: state?.magneticSnap !== false, + snappingMode: SNAPPING_MODES.includes(state?.snappingMode as SnappingMode) + ? (state?.snappingMode as SnappingMode) + : DEFAULT_SNAPPING_MODE, showReferenceFloor: state?.showReferenceFloor === true, referenceFloorOffset: typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 @@ -760,6 +782,10 @@ const useEditor = create()( else if (tool) { set({ tool: null }) } + + const scope = useInteractionScope.getState() + if (mode === 'material-paint') scope.begin({ kind: 'painting' }) + else scope.endIf((s) => s.kind === 'painting') }, tool: DEFAULT_PERSISTED_EDITOR_UI_STATE.tool, setTool: (tool) => set({ tool }), @@ -814,25 +840,68 @@ const useEditor = create()( | null, placementDragMode: false, setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }), - setMovingNode: (node) => - set( - node === null - ? // Preserve `movingNodeOrigin` across the clear so the - // non-owning side's effect cleanup — which fires after - // `setMovingNode(null)` propagates — can still read who - // finalised. The next non-null `setMovingNode` resets it. - // Always clear the press-drag flag when a move ends. - { movingNode: null, placementDragMode: false } - : { movingNode: node, movingNodeOrigin: null }, - ), + setMovingNode: (node) => { + const scope = useInteractionScope.getState() + if (node === null) { + scope.endIf((s) => s.kind === 'placing' || s.kind === 'moving') + // Preserve `movingNodeOrigin` across the clear so the non-owning + // side's effect cleanup — which fires after `setMovingNode(null)` + // propagates — can still read who finalised. The next non-null + // `setMovingNode` resets it. Always clear the press-drag flag. + set({ movingNode: null, placementDragMode: false }) + return + } + const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew) + if (isNew) { + scope.begin({ + kind: 'placing', + nodeId: node.id, + nodeType: node.type, + view: '3d', + pressDrag: get().placementDragMode, + }) + } else { + scope.begin({ kind: 'moving', nodeId: node.id, nodeType: node.type, view: '3d' }) + } + set({ movingNode: node, movingNodeOrigin: null }) + }, movingNodeOrigin: null as '2d' | '3d' | null, setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }), movingWallEndpoint: null, - setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }), + setMovingWallEndpoint: (value) => { + const scope = useInteractionScope.getState() + if (value) scope.begin({ kind: 'reshaping', nodeId: value.wall.id, reshape: 'endpoint' }) + else { + const prev = get().movingWallEndpoint + if (prev) + scope.endIf( + (s) => + s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.wall.id, + ) + } + set({ movingWallEndpoint: value }) + }, movingFenceEndpoint: null, - setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }), + setMovingFenceEndpoint: (value) => { + const scope = useInteractionScope.getState() + if (value) scope.begin({ kind: 'reshaping', nodeId: value.fence.id, reshape: 'endpoint' }) + else { + const prev = get().movingFenceEndpoint + if (prev) + scope.endIf( + (s) => + s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.fence.id, + ) + } + set({ movingFenceEndpoint: value }) + }, activeHandleDrag: null, - setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }), + setActiveHandleDrag: (drag) => { + const scope = useInteractionScope.getState() + if (drag) scope.begin({ kind: 'handle-drag', nodeId: drag.nodeId, handle: drag.label }) + else scope.endIf((s) => s.kind === 'handle-drag') + set({ activeHandleDrag: drag }) + }, rotationAxis: 'y', cycleRotationAxis: () => { const order = ['y', 'x', 'z'] as const @@ -841,9 +910,31 @@ const useEditor = create()( return next }, curvingWall: null, - setCurvingWall: (wall) => set({ curvingWall: wall }), + setCurvingWall: (wall) => { + const scope = useInteractionScope.getState() + if (wall) scope.begin({ kind: 'reshaping', nodeId: wall.id, reshape: 'curve' }) + else { + const prev = get().curvingWall + if (prev) + scope.endIf( + (s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id, + ) + } + set({ curvingWall: wall }) + }, curvingFence: null, - setCurvingFence: (fence) => set({ curvingFence: fence }), + setCurvingFence: (fence) => { + const scope = useInteractionScope.getState() + if (fence) scope.begin({ kind: 'reshaping', nodeId: fence.id, reshape: 'curve' }) + else { + const prev = get().curvingFence + if (prev) + scope.endIf( + (s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id, + ) + } + set({ curvingFence: fence }) + }, selectedMaterialTarget: null, setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), activePaintMaterial: null, @@ -925,7 +1016,24 @@ const useEditor = create()( spaces: {}, setSpaces: (spaces) => set({ spaces }), editingHole: null, - setEditingHole: (hole) => set({ editingHole: hole }), + setEditingHole: (hole) => { + const scope = useInteractionScope.getState() + if (hole) + scope.begin({ + kind: 'reshaping', + nodeId: hole.nodeId, + reshape: 'hole', + holeIndex: hole.holeIndex, + }) + else { + const prev = get().editingHole + if (prev) + scope.endIf( + (s) => s.kind === 'reshaping' && s.reshape === 'hole' && s.nodeId === prev.nodeId, + ) + } + set({ editingHole: hole }) + }, hoveredHole: null, setHoveredHole: (hole) => set((state) => @@ -1007,8 +1115,22 @@ const useEditor = create()( setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep, setGridSnapStep: (step) => set({ gridSnapStep: step }), + cycleGridSnapStep: () => { + const current = get().gridSnapStep + const index = GRID_SNAP_STEPS.indexOf(current) + const next = GRID_SNAP_STEPS[(index + 1) % GRID_SNAP_STEPS.length] ?? GRID_SNAP_STEPS[0]! + set({ gridSnapStep: next }) + return next + }, magneticSnap: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.magneticSnap, setMagneticSnap: (enabled) => set({ magneticSnap: enabled }), + snappingMode: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.snappingMode, + setSnappingMode: (mode) => set({ snappingMode: mode }), + cycleSnappingMode: () => { + const next = nextSnappingMode(get().snappingMode) + set({ snappingMode: next }) + return next + }, showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor, toggleReferenceFloor: () => set((state) => ({ showReferenceFloor: !state.showReferenceFloor })), @@ -1101,6 +1223,7 @@ const useEditor = create()( floorplanSelectionTool: state.floorplanSelectionTool, gridSnapStep: state.gridSnapStep, magneticSnap: state.magneticSnap, + snappingMode: state.snappingMode, showReferenceFloor: state.showReferenceFloor, referenceFloorOffset: state.referenceFloorOffset, referenceFloorOpacity: state.referenceFloorOpacity, @@ -1109,4 +1232,28 @@ const useEditor = create()( ), ) +/** + * 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 diff --git a/packages/editor/src/store/use-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts new file mode 100644 index 00000000..b6315a20 --- /dev/null +++ b/packages/editor/src/store/use-interaction-scope.test.ts @@ -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') + }) +}) diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts new file mode 100644 index 00000000..162f65b8 --- /dev/null +++ b/packages/editor/src/store/use-interaction-scope.ts @@ -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) => 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((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 diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 58eef5b3..f13516ab 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -223,6 +223,7 @@ export const doorDefinition: NodeDefinition = { // Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute // direction + perpendicular for the cutout footprint. floorplan: buildDoorFloorplan, + floorplanDependsOnSiblings: true, // Stage D — placement (`def.tool`) + move-on-wall (`def. // affordanceTools.move`). Both ports of the legacy tools at // `editor/components/tools/door/`, relocated into the kind folder and diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index f53a3651..2525309b 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -11,6 +11,7 @@ import { } from '@pascal-app/core' import { type FencePlanPoint, + isMagneticSnapActive, isSegmentLongEnough, snapFenceDraftPoint, useAlignmentGuides, @@ -171,6 +172,7 @@ export const moveFenceEndpointDragAction: DragAction = { toolHints: [ { key: 'Left click', label: 'Set fence start / end' }, - { key: 'Shift', label: 'Free angle (no 15° snap)' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index 6236a419..c01c9b9f 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -15,6 +15,7 @@ import { alignFloorplanDraftPoint, type FencePlanPoint, getSegmentGridStep, + isMagneticSnapActive, isSegmentLongEnough, snapBuildingLocalToWorldGrid, snapFenceDraftPoint, @@ -165,7 +166,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance = { fences: nextFences, ignoreFenceIds: [node.id], bypassSnap: modifiers.shiftKey, - magnetic: !modifiers.shiftKey, + magnetic: !modifiers.shiftKey && isMagneticSnapActive(), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint, }) // Figma-style alignment on the dragged endpoint — snaps it onto diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index 2d482f5b..09165504 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -15,6 +15,7 @@ import { import { CursorSphere, consumePlacementDragRelease, + isMagneticSnapActive, markToolCancelConsumed, snapFenceDraftPoint, triggerSFX, @@ -201,6 +202,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { fences: levelFences, ignoreFenceIds: [fenceId], bypassSnap, + magnetic: !bypassSnap && isMagneticSnapActive(), }) if ( diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 53b1a8b6..297a7077 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -24,6 +24,8 @@ import { getAngleArcToSegmentReference, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + isAngleSnapActive, + isMagneticSnapActive, markToolCancelConsumed, type SegmentAngleReference, snapFenceDraftPoint, @@ -445,7 +447,6 @@ export const FenceTool: React.FC = () => { const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) - const shiftPressed = useRef(false) const [draftMeasurement, setDraftMeasurement] = useState(null) const measurementColor = isDark ? '#ffffff' : '#111111' const measurementShadowColor = isDark ? '#111111' : '#ffffff' @@ -466,10 +467,13 @@ export const FenceTool: React.FC = () => { } // Align the drafted point onto another object's nearest real anchor and - // publish the guide. Alt bypasses alignment; Shift bypasses all guided - // snapping. Returns the possibly snapped point. + // publish the guide. Alt bypasses alignment. Returns the possibly snapped + // point. const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => { - if (bypass || alignmentCandidates.length === 0) { + // Figma alignment pulls the endpoint onto existing corners / edges, so it + // is a line snap — suppress it whenever magnetic snap is off (`'off'` / + // `'angles'`), matching the fence-geometry snap. + if (bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) { useAlignmentGuides.getState().clear() return point } @@ -494,13 +498,13 @@ export const FenceTool: React.FC = () => { if (!(cursorRef.current && previewRef.current)) return const { walls, fences } = getCurrentLevelElements() const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] - // While drafting, the segment locks to 15° rays from its start - // unless Shift is held. Shift also bypasses grid and magnetic snap. - const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap + // While drafting, the segment locks to 15° rays from its start. + // Snapping is governed by the snapping mode (`'off'` is the bypass); + // there is no Shift hold-to-bypass. Alt still bypasses alignment guides. + const bypassAlign = event.nativeEvent?.altKey === true if (buildingState.current === 1) { - const angleLocked = !bypassSnap + const angleLocked = isAngleSnapActive() const snappedLocal = alignPoint( snapFenceDraftPoint({ point: localPoint, @@ -508,7 +512,7 @@ export const FenceTool: React.FC = () => { fences, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, angleSnap: angleLocked, - bypassSnap, + magnetic: isMagneticSnapActive(), }), bypassAlign || angleLocked, ) @@ -516,7 +520,6 @@ export const FenceTool: React.FC = () => { cursorRef.current.position.copy(endingPoint.current) const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] if ( - !bypassSnap && previousFenceEnd && (currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1]) ) { @@ -543,7 +546,12 @@ export const FenceTool: React.FC = () => { ) } else { const snappedPoint = alignPoint( - snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }), + snapFenceDraftPoint({ + point: localPoint, + walls, + fences, + magnetic: isMagneticSnapActive(), + }), bypassAlign, ) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) @@ -559,12 +567,16 @@ export const FenceTool: React.FC = () => { const { walls, fences } = getCurrentLevelElements() const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] - const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap + const bypassAlign = event.nativeEvent?.altKey === true if (buildingState.current === 0) { const snappedStart = alignPoint( - snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }), + snapFenceDraftPoint({ + point: localClick, + walls, + fences, + magnetic: isMagneticSnapActive(), + }), bypassAlign, ) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) @@ -574,7 +586,7 @@ export const FenceTool: React.FC = () => { previewRef.current.visible = true setDraftMeasurement(null) } else { - const angleLocked = !bypassSnap + const angleLocked = isAngleSnapActive() const snappedEnd = alignPoint( snapFenceDraftPoint({ point: localClick, @@ -582,7 +594,7 @@ export const FenceTool: React.FC = () => { fences, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, angleSnap: angleLocked, - bypassSnap, + magnetic: isMagneticSnapActive(), }), bypassAlign || angleLocked, ) @@ -614,20 +626,6 @@ export const FenceTool: React.FC = () => { } } - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true - } - - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = false - } - - // Cmd-tabbing away mid-draft never delivers the keyup — reset so the - // angle lock isn't stuck off when focus returns. - const onBlur = () => { - shiftPressed.current = false - } - const onCancel = () => { if (buildingState.current === 1) { markToolCancelConsumed() @@ -638,17 +636,11 @@ export const FenceTool: React.FC = () => { emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - window.addEventListener('blur', onBlur) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - window.removeEventListener('blur', onBlur) useSegmentDraftChain.getState().clear('fence') useAlignmentGuides.getState().clear() } diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index f191dcbd..7f408347 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -162,6 +162,7 @@ export const gutterDefinition: NodeDefinition = { parametrics: gutterParametrics, handles: gutterHandles, floorplan: buildGutterFloorplan, + floorplanDependsOnSiblings: true, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index c501c986..bd9164a2 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -315,7 +315,8 @@ export const itemDefinition: NodeDefinition = { { key: 'Left click', label: 'Place item' }, { key: 'R', label: 'Rotate counterclockwise' }, { key: 'T', label: 'Rotate clockwise' }, - { key: 'Shift', label: 'Free place' }, + { key: 'Shift', label: 'Cycle snapping mode' }, + { key: 'Alt', label: 'Free place (no snap)' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/item/floorplan.ts b/packages/nodes/src/item/floorplan.ts index 2a01fa4b..93ab7c9c 100644 --- a/packages/nodes/src/item/floorplan.ts +++ b/packages/nodes/src/item/floorplan.ts @@ -182,6 +182,7 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp }) const isSelected = ctx.viewState?.selected ?? false + const isMoving = ctx.viewState?.moving ?? false const floorPlanUrl = node.asset.floorPlanUrl const children: FloorplanGeometry[] = [ { @@ -214,8 +215,10 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp rotation: transform.rotation, }) } - // Move handle — orange dot at the item center. Only when selected. - if (isSelected) { + // Move handle — orange dot at the item center. Only when selected and not + // already moving: during a move the dot sits under the cursor, so a release + // over it would re-arm the move (and re-enter edit) instead of committing. + if (isSelected && !isMoving) { children.push({ kind: 'move-handle', point: [cx, cy], diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 521c8d9a..ce863c22 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -23,6 +23,7 @@ import { consumePlacementDragRelease, DragBoundingBox, getFloorStackPreviewPosition, + isMagneticSnapActive, resolvePlanarCursorPosition, snapFenceDraftPoint, stripPlacementMetadataFlags, @@ -290,11 +291,13 @@ export const MoveRoofTool: React.FC<{ const y = event.position[1] + const roofBypassSnap = event.nativeEvent?.shiftKey === true const snappedLocal = snapFenceDraftPoint({ point: [event.localPosition[0], event.localPosition[2]], walls: levelWalls, fences: levelFences, - bypassSnap: event.nativeEvent?.shiftKey === true, + bypassSnap: roofBypassSnap, + magnetic: !roofBypassSnap && isMagneticSnapActive(), }) const [rawGridX, , rawGridZ] = localToWorldPoint(snappedLocal, y) const [rawLocalX, rawLocalZ] = computeLocal( diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index 9b2017e1..344ffaa8 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -18,6 +18,7 @@ import { CursorSphere, consumePlacementDragRelease, getSegmentGridStep, + isMagneticSnapActive, markToolCancelConsumed, resolveAlignmentForActiveBuilding, snapBuildingLocalToWorldGrid, @@ -174,6 +175,7 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { walls: levelWalls, fences: levelFences, bypassSnap, + magnetic: !bypassSnap && isMagneticSnapActive(), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, gridStep), }) diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 47356850..f7138351 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -1,5 +1,5 @@ import type { NodeDefinition } from '@pascal-app/core' -import { buildWallFloorplan } from './floorplan' +import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan' import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances' import { wallFloorplanMoveTarget } from './floorplan-move' import { wallFloorplanSiblingOverrides } from './floorplan-overrides' @@ -18,7 +18,8 @@ import { wallSlots } from './slots' * `renderer` + `system` keep wrap-exporting legacy WallRenderer + * WallSystem + WallCutout. * Stage C: `def.floorplan` builder produces the mitered plan footprint - * polygon using `ctx.siblings` to assemble miter context. + * polygon from shared floor-plan level data, with `ctx.siblings` as the + * direct-caller fallback. * floorplan-panel.tsx's `wallPolygons` short-circuits to [] when * wall is registered. */ @@ -98,9 +99,11 @@ export const wallDefinition: NodeDefinition = { // Priority 4 mirrors the legacy WallSystem's useFrame priority. priority: 4, }, - // Stage C: floor-plan rendering. ctx.siblings provides other walls in - // the level so `calculateLevelMiters` can compute correct corner joins. + // Stage C: floor-plan rendering. Precomputes the level miter graph once + // per render pass, then the builder reads its own junctions by wall id. + computeFloorplanLevelData: computeWallFloorplanLevelData, floorplan: buildWallFloorplan, + floorplanDependsOnSiblings: true, // 2D drag affordances triggered by `endpoint-handle` primitives in // `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the // same legacy `MoveWallEndpointTool` flow, reachable from both the @@ -114,7 +117,6 @@ export const wallDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Set wall start / end' }, - { key: 'Shift', label: 'Free angle (no 15° snap)' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index ced8ea75..e408dee1 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -13,6 +13,7 @@ import { import { alignFloorplanDraftPoint, getSegmentGridStep, + isMagneticSnapActive, isSegmentLongEnough, snapBuildingLocalToWorldGrid, snapScalarToGrid, @@ -193,7 +194,7 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { walls, ignoreWallIds: [node.id], bypassSnap: modifiers.shiftKey, - magnetic: !modifiers.shiftKey, + magnetic: !modifiers.shiftKey && isMagneticSnapActive(), gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP), }) // Figma-style alignment on the dragged corner — snaps it onto another diff --git a/packages/nodes/src/wall/floorplan.ts b/packages/nodes/src/wall/floorplan.ts index ff31f008..7260ef80 100644 --- a/packages/nodes/src/wall/floorplan.ts +++ b/packages/nodes/src/wall/floorplan.ts @@ -8,6 +8,7 @@ import { getWallMidpointHandlePoint, getWallPlanFootprint, isCurvedWall, + type WallMiterData, type WallNode, } from '@pascal-app/core' @@ -35,6 +36,15 @@ function formatLengthMetric(meters: number): string { return `${Number.parseFloat(meters.toFixed(2))}m` } +export function computeWallFloorplanLevelData({ + siblings, +}: { + siblings: ReadonlyArray + nodes: Record +}): WallMiterData { + return calculateLevelMiters(siblings.map(exaggerateWallThickness)) +} + /** * Stage C floor-plan builder for wall — emits the full chrome stack the * legacy `floorplan-panel.tsx` rendered inline: @@ -47,21 +57,25 @@ function formatLengthMetric(meters: number): string { * layer hosts the 5-circle stack + hover transitions + 2D drag. * 5. A small dimension label at the midpoint when selected. * - * `ctx.siblings` provides other walls in the level so - * `calculateLevelMiters` computes correct corner joins. - * - * Performance note: this recomputes level miter data per wall (O(N²) - * across N walls in the level). For < 100 walls per level this is - * sub-millisecond. If a real perf hotspot surfaces, the - * `ctx.levelData?.miters` extension flagged in the plan moves the batch - * computation to the dispatcher. + * `ctx.levelData` provides the shared level miter graph when the floor-plan + * dispatcher precomputes it; `ctx.siblings` remains the fallback path for + * direct builder callers. */ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null { - const siblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall') - const all = [node, ...siblings].map(exaggerateWallThickness) - const miters = calculateLevelMiters(all) - const self = all.find((w) => w.id === node.id) - if (!self) return null + const self = exaggerateWallThickness(node) + // Prefer the level-batch miter graph the floor-plan dispatcher precomputes + // once per pass (`computeWallFloorplanLevelData`). Only the fallback path — + // a direct builder caller with no shared data — pays the O(N) exaggerate + + // level-wide miter calc per wall; the dispatcher path is O(1) here, which is + // what keeps a wall drag from being O(N²) across the level. + const miters = + (ctx.levelData as WallMiterData | undefined) ?? + calculateLevelMiters([ + self, + ...ctx.siblings + .filter((s): s is AnyNode & WallNode => s.type === 'wall') + .map(exaggerateWallThickness), + ]) const polygon = getWallPlanFootprint(self, miters) if (!polygon || polygon.length < 3) return null diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index 02d3ec8c..1a8d9575 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -19,6 +19,7 @@ import { formatAngleRadians, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + isMagneticSnapActive, isSegmentLongEnough, MeasurementPill, type MovingWallEndpoint, @@ -295,7 +296,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ walls: levelWalls, ignoreWallIds: [nodeId], bypassSnap, - magnetic: !bypassSnap && useEditor.getState().magneticSnap, + magnetic: !bypassSnap && isMagneticSnapActive(), }) const snappedPoint = snapResult.point diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index ffa2b8d2..ec86c478 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -20,6 +20,8 @@ import { getAngleArcToSegmentReference, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + isAngleSnapActive, + isMagneticSnapActive, markToolCancelConsumed, type SegmentAngleReference, snapWallDraftPointDetailed, @@ -40,8 +42,8 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 * * 1:1 port of the legacy `WallTool`. Two-click flow: click 1 sets the * start, click 2 creates the wall. Between clicks a vertical preview - * rectangle + length/angle measurement HUD follow the pointer. Shift - * bypasses the angle snap; Esc cancels. + * rectangle + length/angle measurement HUD follow the pointer. Snapping is + * governed by the global snapping mode (`'off'` is the bypass); Esc cancels. * * Not a `DragAction` — same reasoning as fence/slab/ceiling placement: * stateful sequence of grid:click events, not a single drag-up. @@ -486,7 +488,6 @@ export const WallTool: React.FC = () => { const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) - const shiftPressed = useRef(false) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) const measurementColor = isDark ? '#ffffff' : '#111111' @@ -508,13 +509,16 @@ export const WallTool: React.FC = () => { } // Align the drafted point onto another object's nearest real anchor and - // publish the guide. Alt bypasses alignment; Shift bypasses all guided - // snapping. Returns the possibly snapped point. + // publish the guide. Alt bypasses alignment. Returns the possibly snapped + // point. const alignPoint = ( point: WallPlanPoint, options: { applySnap?: boolean; bypass?: boolean }, ): WallPlanPoint => { - if (options.bypass || alignmentCandidates.length === 0) { + // Figma alignment pulls the endpoint onto existing wall corners / edges, + // so it is a line snap — suppress it whenever magnetic snap is off + // (`'off'` / `'angles'`), matching the wall-geometry snap above. + if (options.bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) { useAlignmentGuides.getState().clear() return point } @@ -546,19 +550,17 @@ export const WallTool: React.FC = () => { const walls = getCurrentLevelWalls() const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - // Default path: grid + magnetic snap, with 15° angle lock while - // drafting. Shift is a hard snap bypass: no grid, magnetic, angle, - // or alignment snap. - const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const angleLocked = buildingState.current === 1 && !bypassSnap - const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap + // Snapping is governed entirely by the snapping mode (grid / lines / + // angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass. + // Alt still bypasses Figma-style alignment guides independently. + const angleLocked = buildingState.current === 1 && isAngleSnapActive() + const bypassAlign = event.nativeEvent?.altKey === true const snapResult = snapWallDraftPointDetailed({ point: localPoint, walls, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, angleSnap: angleLocked, - bypassSnap, - magnetic: !bypassSnap && useEditor.getState().magneticSnap, + magnetic: isMagneticSnapActive(), }) gridPosition = alignPoint(snapResult.point, { applySnap: !angleLocked, @@ -590,7 +592,6 @@ export const WallTool: React.FC = () => { const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] if ( - !bypassSnap && previousWallEnd && (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1]) ) { @@ -633,16 +634,14 @@ export const WallTool: React.FC = () => { const walls = getCurrentLevelWalls() const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap + const bypassAlign = event.nativeEvent?.altKey === true if (buildingState.current === 0) { const snappedStart = alignPoint( snapWallDraftPointDetailed({ point: localClick, walls, - bypassSnap, - magnetic: !bypassSnap && useEditor.getState().magneticSnap, + magnetic: isMagneticSnapActive(), }).point, { bypass: bypassAlign }, ) @@ -665,15 +664,14 @@ export const WallTool: React.FC = () => { // `onGridMove` writes a real BoxGeometry skips that frame. setDraftMeasurement(null) } else if (buildingState.current === 1) { - const angleLocked = !bypassSnap + const angleLocked = isAngleSnapActive() const snappedEnd = alignPoint( snapWallDraftPointDetailed({ point: localClick, walls, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, angleSnap: angleLocked, - bypassSnap, - magnetic: !bypassSnap && useEditor.getState().magneticSnap, + magnetic: isMagneticSnapActive(), }).point, { applySnap: !angleLocked, @@ -729,20 +727,6 @@ export const WallTool: React.FC = () => { } } - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true - } - - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = false - } - - // Cmd-tabbing away mid-draft never delivers the keyup — reset so the - // angle lock isn't stuck off when focus returns. - const onBlur = () => { - shiftPressed.current = false - } - const onCancel = () => { if (buildingState.current === 1) { markToolCancelConsumed() @@ -753,17 +737,11 @@ export const WallTool: React.FC = () => { emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - window.addEventListener('blur', onBlur) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - window.removeEventListener('blur', onBlur) useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() useSegmentDraftChain.getState().clear('wall') diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index a083879c..ee5538ac 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -209,6 +209,7 @@ export const windowDefinition: NodeDefinition = { // Stage C: floor-plan polygon. ctx.parent gives the wall for direction // + thickness — same shape as door. floorplan: buildWindowFloorplan, + floorplanDependsOnSiblings: true, // Stage D — placement + move-on-wall. Same recipe as door. See // `nodes/src/window/{tool,move-tool,window-math}.ts`. tool: () => import('./tool'), diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 04763991..df68ee7d 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1,16 +1,19 @@ import { type AnyNodeId, clampDoorOperationState, + DEFAULT_WALL_THICKNESS, type DoorNode, DoorNode as DoorNodeSchema, getDoorRenderOpenAmount, getEffectiveNode, + getWallThickness, type SceneMaterial, type SceneMaterialId, sceneRegistry, useInteractive, useLiveNodeOverrides, useScene, + type WallNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' @@ -26,6 +29,7 @@ import { resolveMaterialRef, } from '../../lib/materials' import useViewer from '../../store/use-viewer' +import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) @@ -186,6 +190,19 @@ function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh { return mesh } +const NO_RAYCAST = () => {} + +// An open door leaf swings perpendicular to the wall, so in a top-down view its +// flat panel blankets the room interior and wins the selection raycast over the +// slab/items beneath it. Drop the swung leaf out of the raycast so a click on +// the floor falls through to what's underneath; the door stays selectable via +// its proud invisible cutout proxy at the opening (see syncDoorCutout). +function disableSubtreeRaycast(object: THREE.Object3D) { + object.traverse((child) => { + ;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST + }) +} + function nodeReferencesSceneMaterial(node: { slots?: Record }): boolean { const slots = node.slots if (!slots) return false @@ -1323,6 +1340,14 @@ function addDoorLeaf( ) addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) } + + // When the leaf is swung open it projects into the room and would otherwise + // win a top-down selection click over the floor beneath it. Drop only the + // swung leaf out of the raycast; a closed leaf stays in the wall plane and + // keeps its hit-eligibility (so paint-by-slot still works on it). + if (Math.abs(swingRotation) > 1e-3) { + disableSubtreeRaycast(leafGroup) + } } function addFoldingDoor( @@ -2556,18 +2581,21 @@ function hideEmptyGeometryMeshes(root: THREE.Object3D) { } function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { - // ── Cutout (for wall CSG) — always full door dimensions, 1m deep ── + // ── Cutout: invisible raycast hit target for the whole opening ── let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined if (!cutout) { cutout = new THREE.Mesh() cutout.name = 'cutout' - // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it - // wins the scene raycast over the wall in front of the recessed door body — - // making it the selection AND paint hit target for the whole opening. The - // paint capability then re-raycasts the door's parts to find the slot. + // The cutout (invisible) is proud of the wall on both faces, so it wins the + // scene raycast over the wall in front of the recessed door body — making it + // the selection AND paint hit target for the whole opening. The paint + // capability then re-raycasts the door's parts to find the slot. Its depth + // is snug to the wall (not 1m) so it no longer blankets the room floor in a + // top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth). mesh.add(cutout) } cutout.geometry.dispose() + const depth = resolveOpeningCutoutProxyDepth(node) const openingShape = getEffectiveOpeningShape(node) if (openingShape === 'arch') { cutout.geometry = new THREE.ExtrudeGeometry( @@ -2579,12 +2607,12 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { getClampedArchHeight(node.width, node.height, node.archHeight), ), { - depth: 1, + depth, bevelEnabled: false, curveSegments: 24, }, ) - cutout.geometry.translate(0, 0, -0.5) + cutout.geometry.translate(0, 0, -depth / 2) } else if (openingShape === 'rounded') { cutout.geometry = new THREE.ExtrudeGeometry( createRoundedTopShape( @@ -2595,18 +2623,30 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { getDoorTopRadii(node, node.width, node.height), ), { - depth: 1, + depth, bevelEnabled: false, curveSegments: 24, }, ) - cutout.geometry.translate(0, 0, -0.5) + cutout.geometry.translate(0, 0, -depth / 2) } else { - cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth) } cutout.visible = false } +// Resolve the cutout proxy depth from the opening's parent wall thickness so +// the proxy stays proud of both wall faces (front/back selection) without the +// old 1m depth that blanketed the floor. Falls back to the default thickness +// when the parent wall isn't a resolvable wall node. +function resolveOpeningCutoutProxyDepth(node: DoorNode): number { + const parentId = node.parentId + const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined + const wallThickness = + parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS + return getOpeningCutoutProxyDepth(wallThickness) +} + /** * Build a fresh door mesh for preview/ghost rendering. * Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware). diff --git a/packages/viewer/src/systems/wall/opening-cutout-geometry.ts b/packages/viewer/src/systems/wall/opening-cutout-geometry.ts index ea2e8c01..f6bf89d9 100644 --- a/packages/viewer/src/systems/wall/opening-cutout-geometry.ts +++ b/packages/viewer/src/systems/wall/opening-cutout-geometry.ts @@ -10,6 +10,20 @@ export type OpeningCutoutRect = { top: number } +// The cutout proxy doubles as the invisible raycast hit target for an opening: +// centered on the wall and extending past both faces so it wins the scene +// raycast over the recessed door/window body for front AND back selection + +// paint. It only needs to clear the wall thickness plus a small proud margin — +// the wall CSG brush ignores this proxy's depth entirely (it rebuilds its own +// full-thickness box from the proxy's X/Y bounds in `collectCutoutBrushes`), so +// a snug depth keeps the cut intact while no longer blanketing the room floor in +// a top-down view (the bug a 1m-deep proxy caused in narrow hallways). +const OPENING_CUTOUT_PROXY_PROUD_MARGIN = 0.08 + +export function getOpeningCutoutProxyDepth(wallThickness: number): number { + return Math.max(wallThickness, 0) + OPENING_CUTOUT_PROXY_PROUD_MARGIN +} + type CornerRadii = { topLeft: number topRight: number diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 30b9a15e..6f1c71d5 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,12 +1,15 @@ import { type AnyNodeId, + DEFAULT_WALL_THICKNESS, getEffectiveNode, + getWallThickness, type SceneMaterial, type SceneMaterialId, sceneRegistry, useInteractive, useLiveNodeOverrides, useScene, + type WallNode, type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' @@ -22,6 +25,7 @@ import { resolveMaterialRef, } from '../../lib/materials' import useViewer from '../../store/use-viewer' +import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) @@ -163,6 +167,21 @@ function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh { return mesh } +const NO_RAYCAST = () => {} + +// An open casement sash swings perpendicular to the wall, so in a top-down view +// its flat panel blankets the room interior and wins the selection raycast over +// the slab/items beneath it. Drop the swung sash out of the raycast so a floor +// click falls through; the window stays selectable via its proud invisible +// cutout proxy at the opening (see syncWindowCutout). Skipped while closed so +// paint-by-slot still resolves on the sash. +function disableSubtreeRaycastIfSwung(object: THREE.Object3D, rotationY: number) { + if (Math.abs(rotationY) <= 1e-3) return + object.traverse((child) => { + ;(child as unknown as { raycast: () => void }).raycast = NO_RAYCAST + }) +} + function nodeReferencesSceneMaterial(node: { slots?: Record }): boolean { const slots = node.slots if (!slots) return false @@ -1020,6 +1039,8 @@ function addRectCasementSash( ) currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) + + disableSubtreeRaycastIfSwung(sash, rotationY) } function addFrenchCasementHingeMarkers( @@ -1244,6 +1265,7 @@ function addShapedFrenchCasementSash( sashDepth * 0.08, ) } + disableSubtreeRaycastIfSwung(sash, rotationY) return } @@ -1271,6 +1293,7 @@ function addShapedFrenchCasementSash( sashDepth * 0.08, ) } + disableSubtreeRaycastIfSwung(sash, rotationY) } function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { @@ -1534,6 +1557,8 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + disableSubtreeRaycastIfSwung(sash, sash.rotation.y) + currentWindowSlot = 'frame' addBox( mesh, @@ -1696,6 +1721,8 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) + disableSubtreeRaycastIfSwung(sash, sash.rotation.y) + // Small hinge markers make the pivot side legible when the sash is closed. currentWindowSlot = 'frame' addBox( @@ -3597,20 +3624,23 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { } function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { - // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ── + // ── Cutout: invisible raycast hit target for the whole opening ── let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined if (!cutout) { cutout = new THREE.Mesh() cutout.name = 'cutout' - // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it - // wins the scene raycast over the wall in front of the recessed window — - // making it the selection AND paint hit target for the whole opening. The - // paint capability then re-raycasts the window's parts to find the slot. + // The cutout (invisible) is proud of the wall on both faces, so it wins the + // scene raycast over the wall in front of the recessed window — making it + // the selection AND paint hit target for the whole opening. The paint + // capability then re-raycasts the window's parts to find the slot. Its depth + // is snug to the wall (not 1m) so it no longer blankets the room floor in a + // top-down view; the wall CSG ignores this depth (see getOpeningCutoutProxyDepth). mesh.add(cutout) } cutout.geometry.dispose() + const depth = resolveOpeningCutoutProxyDepth(node) if (isRectangleOnlyWindowType(node)) { - cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth) } else if (node.openingShape === 'arch') { cutout.geometry = new THREE.ExtrudeGeometry( createArchShape( @@ -3621,12 +3651,12 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { getClampedArchHeight(node.width, node.height, node.archHeight), ), { - depth: 1, + depth, bevelEnabled: false, curveSegments: 24, }, ) - cutout.geometry.translate(0, 0, -0.5) + cutout.geometry.translate(0, 0, -depth / 2) } else if (node.openingShape === 'rounded') { cutout.geometry = new THREE.ExtrudeGeometry( createRoundedShape( @@ -3637,18 +3667,30 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { getWindowRoundedRadii(node, node.width, node.height), ), { - depth: 1, + depth, bevelEnabled: false, curveSegments: 24, }, ) - cutout.geometry.translate(0, 0, -0.5) + cutout.geometry.translate(0, 0, -depth / 2) } else { - cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, depth) } cutout.visible = false } +// Resolve the cutout proxy depth from the opening's parent wall thickness so +// the proxy stays proud of both wall faces (front/back selection) without the +// old 1m depth that blanketed the floor. Falls back to the default thickness +// when the parent wall isn't a resolvable wall node. +function resolveOpeningCutoutProxyDepth(node: WindowNode): number { + const parentId = node.parentId + const parent = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined + const wallThickness = + parent?.type === 'wall' ? getWallThickness(parent as WallNode) : DEFAULT_WALL_THICKNESS + return getOpeningCutoutProxyDepth(wallThickness) +} + /** * Build a fresh window mesh for preview/ghost rendering. * Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware). diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index 06db6cda..b66db49d 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -14,6 +14,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [item-authoring](item-authoring.md) | Content-author contract for catalog item GLBs: `slot_` material naming, authored defaults + `pascal_material` extras, the `cutout` reserved mesh, UV world scale, and the validated Blender/export recipe | | [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 | | [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults | +| [interaction-scope](interaction-scope.md) | The authoritative interaction state machine ("the spine"): `InteractionScope` union, the begin/update/end/endIf contract, the raycast hot-set, and the overlay scope matrix | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | @@ -25,4 +26,5 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa ## Reading order for an architecture review 1. [layers](layers.md), [systems](systems.md), [renderers](renderers.md), [tools](tools.md), [viewer-isolation](viewer-isolation.md) — required every review. + - When the diff touches placement / move / handle / reshape / box-select / paint or any overlay or picking behaviour, also read [interaction-scope](interaction-scope.md). 2. The remaining pages on demand, based on what the diff touches. diff --git a/wiki/architecture/interaction-scope.md b/wiki/architecture/interaction-scope.md new file mode 100644 index 00000000..05186e67 --- /dev/null +++ b/wiki/architecture/interaction-scope.md @@ -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. diff --git a/wiki/architecture/selection-managers.md b/wiki/architecture/selection-managers.md index ad04a46e..7958d13b 100644 --- a/wiki/architecture/selection-managers.md +++ b/wiki/architecture/selection-managers.md @@ -36,6 +36,12 @@ return 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 diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 8ef982ab..2ab42b2d 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -12,6 +12,8 @@ Tools are React components that capture user input (pointer, keyboard) and trans See `apps/editor/components/tools/tool-manager.tsx`. +> **What the user is doing right now** is owned by the interaction state machine, not by tool-local flags. A tool that starts a placement / move / handle / reshape / box-select / paint interaction enters it through `useInteractionScope.begin(...)` and leaves through `end()` — see [interaction-scope](interaction-scope.md). Do not add a new `useEditor` flag for a new interaction. + ## Tool Categories by Phase **Site**