From f7ff60561e658251280f83408ed565a7a36516e2 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 2 Jun 2026 10:17:15 -0400 Subject: [PATCH] =?UTF-8?q?feat(editor):=20preset=20placement=20polish=20?= =?UTF-8?q?=E2=80=94=20params,=20move=20tool,=20shadows,=20isolation=20(#3?= =?UTF-8?q?57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): preset placement polish — params, move tool, shadows - wall/slab/ceiling/roof create paths consume `toolDefaults` so template presets build with their saved params; cleared on tool unmount - wall draw preview reflects the preset's height/thickness (+ HUD labels) - box-select picks up registry-selectable kinds (shelf) via bbox - registry + column move tools: snap to the active grid step, R/T rotation, and ignore the stray trailing click that armed the move (no double-place) - shelf geometry casts + receives shadows like fence/slab Co-Authored-By: Claude Opus 4.8 * feat(viewer): expose isIsolationActive() for isolation-aware consumers Tracks whether an isolation filter is currently applied and exposes it so hosts can avoid acting on the partial view — e.g. skipping project-thumbnail autosave while a single subtree is isolated (preset capture). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../registry/move-registry-node-tool.tsx | 87 ++++++++++++++++--- .../src/components/tools/roof/roof-tool.tsx | 25 ++++-- .../tools/select/box-select-tool.tsx | 8 ++ .../components/tools/wall/wall-drafting.ts | 5 ++ packages/nodes/src/ceiling/tool.tsx | 17 +++- packages/nodes/src/column/move-tool.tsx | 74 +++++++++++++--- packages/nodes/src/shelf/geometry.ts | 7 ++ packages/nodes/src/slab/tool.tsx | 17 +++- packages/nodes/src/wall/tool.tsx | 63 +++++++++++--- packages/viewer/src/index.ts | 7 +- packages/viewer/src/lib/isolation.ts | 13 +++ 11 files changed, 277 insertions(+), 46 deletions(-) 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 f070c9d0..977bb151 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 @@ -20,7 +20,15 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' -const roundToHalf = (value: number) => Math.round(value * 2) / 2 +/** 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 + return Math.round(value / step) * step +} + +/** 90° steps, matching the GLB item placement rotation. */ +const ROTATION_STEP = Math.PI / 2 /** * Generic move tool for any registry-backed kind. @@ -104,6 +112,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { * commit position consistent with the visible cursor. */ const lastCursorRef = useRef<[number, number, number]>(originalPosition) + /** + * Becomes true on the first `grid:move` after this move arms. Commits are + * ignored until then so a click that *armed* this move (e.g. the trailing + * `click` event of the click that just committed the previous copy, when a + * preset placement immediately re-arms the next one) can't auto-drop a + * second copy at the spot. Every real placement moves the cursor into + * position before the drop click, so this never blocks a legitimate commit. + */ + const hasMovedRef = useRef(false) + // Live Y-rotation during the drag, seeded from the node's current rotation + // and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`, + // and committed to the scene on drop. + const rotationRef = useRef(originalRotationY) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -112,8 +133,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useEffect(() => { useScene.temporal.getState().pause() previousSnapRef.current = null + hasMovedRef.current = false + rotationRef.current = originalRotationY let committed = false + // The node's rotation shape (tuple vs scalar) is preserved on commit; + // only the Y angle changes. Most registry kinds use a `[x, y, z]` tuple. + const baseRotation = (node as { rotation?: unknown }).rotation + const toCommitRotation = (y: number): number | [number, number, number] => + Array.isArray(baseRotation) + ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0] + : y + // Disable raycast on the moved node's meshes for the duration of // the drag. As the shelf follows the cursor, the cursor ray would // otherwise hit the moved mesh first → only `${kind}:move` fires → @@ -135,8 +166,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } const onGridMove = (event: GridEvent) => { - const x = roundToHalf(event.localPosition[0]) - const z = roundToHalf(event.localPosition[2]) + const x = snapToGridStep(event.localPosition[0]) + const z = snapToGridStep(event.localPosition[2]) + hasMovedRef.current = true setCursorPosition([x, 0, z]) lastCursorRef.current = [x, 0, z] @@ -154,7 +186,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // their floor-plan move-targets handle the override themselves. useLiveTransforms.getState().set(node.id, { position: [x, 0, z], - rotation: originalRotationY, + rotation: rotationRef.current, }) const prev = previousSnapRef.current @@ -180,11 +212,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { * AND scene updated) — never the original. */ const commitAtCursor = (event: ClickTriggerEvent) => { + // Ignore a commit that fires before the cursor has moved into place — + // it's the stray trailing click of whatever armed this move, not a + // deliberate drop. Prevents preset re-arm from double-placing. + if (!hasMovedRef.current) return const position: [number, number, number] = [...lastCursorRef.current] + const rotation = toCommitRotation(rotationRef.current) + if (useScene.getState().nodes[node.id]) { useScene.temporal.getState().resume() - useScene.getState().updateNode(node.id, { position } as Partial) + useScene.getState().updateNode(node.id, { position, rotation } as Partial) useScene.temporal.getState().pause() committed = true } else if (node.parentId) { @@ -196,6 +234,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { id: undefined, metadata: {}, position, + rotation, }) useScene.temporal.getState().resume() useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId) @@ -204,11 +243,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } - // Keep mesh.position aligned with the just-committed scene position - // so the next R3F frame paints at the right spot even if React's + // Keep mesh.position/rotation aligned with the just-committed scene + // values so the next R3F frame paints correctly even if React's // reconciliation lags by a tick. const mesh = sceneRegistry.nodes.get(node.id) - if (mesh) mesh.position.set(position[0], position[1], position[2]) + if (mesh) { + mesh.position.set(position[0], position[1], position[2]) + mesh.rotation.y = rotationRef.current + } // Now safe to clear — node.position is already the new value, so // `ParametricNodeRenderer`'s next render lands at `[x, 0, z]`. @@ -230,6 +272,26 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (typeof direct === 'function') direct.call(event) } + // R / T rotate the dragged node about Y in 90° steps — matching the GLB + // 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) => { + if (e.metaKey || e.ctrlKey || e.altKey) return + let delta = 0 + if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP + else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP + else return + e.preventDefault() + rotationRef.current += delta + const m = sceneRegistry.nodes.get(node.id) + if (m) m.rotation.y = rotationRef.current + useLiveTransforms.getState().set(node.id, { + position: lastCursorRef.current, + rotation: rotationRef.current, + }) + } + window.addEventListener('keydown', onKeyDown) + emitter.on('grid:move', onGridMove) emitter.on('grid:click', commitAtCursor) @@ -244,9 +306,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } const onCancel = () => { - sceneRegistry.nodes - .get(node.id) - ?.position.set(originalPosition[0], originalPosition[1], originalPosition[2]) + const m = sceneRegistry.nodes.get(node.id) + if (m) { + m.position.set(originalPosition[0], originalPosition[1], originalPosition[2]) + m.rotation.y = originalRotationY + } useLiveTransforms.getState().clear(node.id) useScene.temporal.getState().resume() markToolCancelConsumed() @@ -255,6 +319,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { emitter.on('tool:cancel', onCancel) return () => { + window.removeEventListener('keydown', onKeyDown) emitter.off('grid:move', onGridMove) emitter.off('grid:click', commitAtCursor) for (const kind of CLICK_TRIGGER_KINDS) { diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index b5ec6930..d5c4fd23 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -34,6 +34,13 @@ const commitRoofPlacement = ( ): AnyNode['id'] => { const { createNode, createNodes, nodes } = useScene.getState() + // A placed roof preset seeds `toolDefaults.roof` with the flattened + // subtree params (roofType, pitch, wallHeight, overhang, materials, …) + // before the tool activates. The footprint (width/depth) and placement + // come from the drawn rectangle and always win; the segment carries the + // shape/material params, the roof container picks up the materials. + const defaults = useEditor.getState().toolDefaults.roof ?? {} + const centerX = (corner1[0] + corner2[0]) / 2 const centerZ = (corner1[2] + corner2[2]) / 2 @@ -74,11 +81,12 @@ const commitRoofPlacement = ( } const segment = RoofSegmentNode.parse({ - width, - depth, wallHeight: DEFAULT_WALL_HEIGHT, pitch: DEFAULT_PITCH_DEG, roofType: 'gable', + ...defaults, + width, + depth, position: [localX, 0, localZ], }) @@ -93,16 +101,19 @@ const commitRoofPlacement = ( // Create the segment first (centered in its new parent) const segment = RoofSegmentNode.parse({ - width, - depth, wallHeight: DEFAULT_WALL_HEIGHT, pitch: DEFAULT_PITCH_DEG, roofType: 'gable', + ...defaults, + width, + depth, position: [0, 0, 0], }) - // Create the roof container + // Create the roof container. Segment-shaped params (roofType, pitch, …) are + // dropped by the RoofNode schema; surface materials in `defaults` carry over. const roof = RoofNode.parse({ + ...defaults, name, position: [centerX, 0, centerZ], children: [segment.id], @@ -138,6 +149,10 @@ export const RoofTool: React.FC = () => { selectedIdsRef.current = selectedIds }, [selectedIds]) + // Clear preset-seeded defaults on deactivation so a later manual roof draw + // isn't built with a stale preset's parameters. Unmount-only. + useEffect(() => () => useEditor.getState().setToolDefaults('roof', null), []) + const corner1Ref = useRef<[number, number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) const [preview, setPreview] = useState({ 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 6275874d..2af79ac1 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -8,6 +8,7 @@ import { emitter, type GridEvent, type ItemNode, + isRegistrySelectable, type LevelNode, type SlabNode, sceneRegistry, @@ -272,6 +273,13 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] { if (xz && pointInBounds(xz[0], xz[1], bounds)) { result.push(item.id) } + } else if (isRegistrySelectable(node.type)) { + // Registry-driven selectable kinds (shelf + future furnish/structure + // kinds) aren't in the hardcoded list above; pick them up by their + // rendered bounding box, the same path column/stair use. + if (objectBoundsIntersectsBounds(node.id, bounds)) { + result.push(node.id) + } } } } diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 1c443e1e..1fa49604 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -507,7 +507,12 @@ export function createWallOnCurrentLevel( } const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length + // A placed wall preset seeds `toolDefaults.wall` (thickness, height, + // materials, sides) before the tool activates; merge those first so the + // drawn wall reproduces the preset. Identity + endpoints always win. + const defaults = useEditor.getState().toolDefaults.wall ?? {} const wall = WallSchema.parse({ + ...defaults, name: `Wall ${wallCount + 1}`, start: resolvedStart, end: resolvedEnd, diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index 84226f1f..57f69349 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -1,7 +1,13 @@ 'use client' import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' -import { CursorSphere, EDITOR_LAYER, markToolCancelConsumed, triggerSFX } from '@pascal-app/editor' +import { + CursorSphere, + EDITOR_LAYER, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' @@ -46,7 +52,10 @@ function commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, n const { createNode, nodes } = useScene.getState() const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length const name = `Ceiling ${ceilingCount + 1}` - const ceiling = CeilingNode.parse({ name, polygon: points }) + // A placed ceiling preset seeds `toolDefaults.ceiling` (thickness, height, + // material, …) before the tool activates; the drawn polygon always wins. + const defaults = useEditor.getState().toolDefaults.ceiling ?? {} + const ceiling = CeilingNode.parse({ ...defaults, name, polygon: points }) createNode(ceiling, levelId) triggerSFX('sfx:structure-build') return ceiling.id @@ -70,6 +79,10 @@ export const CeilingTool: React.FC = () => { const previousSnappedPointRef = useRef<[number, number] | null>(null) const shiftPressed = useRef(false) + // Clear preset-seeded defaults on deactivation so a later manual ceiling + // draw isn't built with a stale preset's parameters. Unmount-only. + useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), []) + const verticalGeo = useMemo( () => new BufferGeometry().setFromPoints([ diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index 661ca981..d16c446c 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -28,7 +28,14 @@ import { useCallback, useEffect, useState } from 'react' * dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup * picks this up before its legacy chain reaches ``. */ -const roundToHalf = (value: number) => Math.round(value * 2) / 2 +/** Snap to the editor's active grid step (0.5 / 0.25 / 0.1 / 0.05), read live. */ +const snapToGridStep = (value: number) => { + const step = useEditor.getState().gridSnapStep + return Math.round(value / step) * step +} + +/** 90° steps, matching the GLB item / shelf placement rotation. */ +const ROTATION_STEP = Math.PI / 2 function MoveColumnTool({ node }: { node: ColumnNode }) { const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) @@ -40,6 +47,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { useEffect(() => { useScene.temporal.getState().pause() let committed = false + // Ignore a commit before the cursor has moved into place: it's the stray + // trailing click of whatever armed this move (e.g. a preset re-arming the + // next copy right after a placement click), not a deliberate drop. + let hasMoved = false + // Live Y-rotation, seeded from the column and bumped by R/T. + let rotationY = node.rotation + // Latest previewed position, so an R/T press can re-apply at the spot. + let lastPosition: [number, number, number] = node.position const meta = typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) @@ -47,23 +62,47 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const isNew = !!meta.isNew const applyPreview = (position: [number, number, number]) => { + lastPosition = position setPreviewPosition(position) useLiveTransforms.getState().set(node.id, { position, - rotation: node.rotation, + rotation: rotationY, }) - sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2]) + const m = sceneRegistry.nodes.get(node.id) + if (m) { + m.position.set(position[0], position[1], position[2]) + m.rotation.y = rotationY + } } const onGridMove = (event: GridEvent) => { - applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]) + hasMoved = true + applyPreview([ + snapToGridStep(event.localPosition[0]), + 0, + snapToGridStep(event.localPosition[2]), + ]) + } + + // R / T rotate the dragged column about Y in 90° steps (matches the move + // HUD's "Rotate" hints), committed on drop. + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return + let delta = 0 + if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP + else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP + else return + e.preventDefault() + rotationY += delta + applyPreview(lastPosition) } const onGridClick = (event: GridEvent) => { + if (!hasMoved) return const position: [number, number, number] = [ - roundToHalf(event.localPosition[0]), + snapToGridStep(event.localPosition[0]), 0, - roundToHalf(event.localPosition[2]), + snapToGridStep(event.localPosition[2]), ] const nodeId = (node as { id?: ColumnNode['id'] }).id @@ -71,13 +110,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { committed = true useLiveTransforms.getState().clear(nodeId) useScene.temporal.getState().resume() - useScene.getState().updateNode(nodeId, { position, ...(isNew ? { metadata: {} } : {}) }) + useScene + .getState() + .updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) }) } else if (node.parentId) { const column = ColumnNodeSchema.parse({ ...node, id: undefined, metadata: {}, position, + rotation: rotationY, }) committed = true useScene.temporal.getState().resume() @@ -92,27 +134,33 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const onCancel = () => { useLiveTransforms.getState().clear(node.id) - sceneRegistry.nodes - .get(node.id) - ?.position.set(node.position[0], node.position[1], node.position[2]) + const m = sceneRegistry.nodes.get(node.id) + if (m) { + m.position.set(node.position[0], node.position[1], node.position[2]) + m.rotation.y = node.rotation + } useScene.temporal.getState().resume() markToolCancelConsumed() exitMoveMode() } + window.addEventListener('keydown', onKeyDown) emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) return () => { + window.removeEventListener('keydown', onKeyDown) emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) useLiveTransforms.getState().clear(node.id) if (!committed) { - sceneRegistry.nodes - .get(node.id) - ?.position.set(node.position[0], node.position[1], node.position[2]) + const m = sceneRegistry.nodes.get(node.id) + if (m) { + m.position.set(node.position[0], node.position[1], node.position[2]) + m.rotation.y = node.rotation + } useScene.temporal.getState().resume() } } diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index 3e53c8c6..63d3841d 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -93,6 +93,13 @@ export function buildShelfGeometry( break } + // Boards/brackets cast + receive shadows like the other geometry-driven + // kinds (fence, slab). Set once here rather than on every `new Mesh` above. + for (const child of group.children) { + child.castShadow = true + child.receiveShadow = true + } + return group } diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index c6545d1c..07f298f1 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -1,7 +1,13 @@ 'use client' import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' -import { CursorSphere, EDITOR_LAYER, markToolCancelConsumed, triggerSFX } from '@pascal-app/editor' +import { + CursorSphere, + EDITOR_LAYER, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' @@ -47,7 +53,10 @@ function commitSlabDrawing(levelId: LevelNode['id'], points: Array<[number, numb const { createNode, nodes } = useScene.getState() const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length const name = `Slab ${slabCount + 1}` - const slab = SlabNode.parse({ name, polygon: points }) + // A placed slab preset seeds `toolDefaults.slab` (thickness, material, …) + // before the tool activates; the drawn polygon always wins. + const defaults = useEditor.getState().toolDefaults.slab ?? {} + const slab = SlabNode.parse({ ...defaults, name, polygon: points }) createNode(slab, levelId) triggerSFX('sfx:structure-build') return slab.id @@ -67,6 +76,10 @@ export const SlabTool: React.FC = () => { const previousSnappedPointRef = useRef<[number, number] | null>(null) const shiftPressed = useRef(false) + // Clear preset-seeded defaults on deactivation so a later manual slab draw + // isn't built with a stale preset's parameters. Unmount-only. + useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), []) + useEffect(() => { if (!currentLevelId) return diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index d9d74305..b143c777 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -21,6 +21,7 @@ import { type SegmentAngleReference, snapWallDraftPoint, triggerSFX, + useEditor, WALL_FINE_GRID_STEP, type WallPlanPoint, } from '@pascal-app/editor' @@ -44,9 +45,11 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 */ const WALL_HEIGHT = 2.5 const DRAFT_WALL_THICKNESS = 0.1 -const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22 -const DRAFT_ANGLE_LABEL_Y = WALL_HEIGHT + 0.08 -const DRAFT_ANGLE_ARC_Y = WALL_HEIGHT + 0.012 +// HUD label heights are measured from the top of the preview bar, so they +// track whatever height a seeded preset draws at (`previewHeight`). +const DRAFT_LABEL_Y_OFFSET = 0.22 +const DRAFT_ANGLE_LABEL_Y_OFFSET = 0.08 +const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012 const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 const DRAFT_ANGLE_ARC_SEGMENTS = 24 @@ -259,6 +262,7 @@ function getDraftAngleLabels( end: WallPlanPoint, walls: WallNode[], baseY: number, + previewHeight: number, ): DraftAngleLabel[] { const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]] const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]] @@ -313,7 +317,7 @@ function getDraftAngleLabels( label: formatAngleRadians(angle), position: [ arcCenter[0] + Math.cos(arc.midAngle) * (radius + 0.16), - baseY + DRAFT_ANGLE_LABEL_Y, + baseY + previewHeight + DRAFT_ANGLE_LABEL_Y_OFFSET, arcCenter[1] + Math.sin(arc.midAngle) * (radius + 0.16), ], arc: { @@ -321,7 +325,7 @@ function getDraftAngleLabels( radius, startAngle: arc.startAngle, endAngle: arc.endAngle, - y: baseY + DRAFT_ANGLE_ARC_Y, + y: baseY + previewHeight + DRAFT_ANGLE_ARC_Y_OFFSET, }, }) } @@ -335,6 +339,7 @@ function getDraftMeasurementState( walls: WallNode[], unit: 'metric' | 'imperial', baseY: number, + previewHeight: number, ): DraftMeasurementState { const dx = end[0] - start[0] const dz = end[1] - start[1] @@ -342,12 +347,22 @@ function getDraftMeasurementState( if (length < 0.01) return null return { lengthLabel: formatMeasurement(length, unit), - lengthPosition: [(start[0] + end[0]) / 2, baseY + DRAFT_LABEL_Y, (start[1] + end[1]) / 2], - angleLabels: getDraftAngleLabels(start, end, walls, baseY), + lengthPosition: [ + (start[0] + end[0]) / 2, + baseY + previewHeight + DRAFT_LABEL_Y_OFFSET, + (start[1] + end[1]) / 2, + ], + angleLabels: getDraftAngleLabels(start, end, walls, baseY, previewHeight), } } -function updateWallPreview(mesh: Mesh, start: Vector3, end: Vector3) { +function updateWallPreview( + mesh: Mesh, + start: Vector3, + end: Vector3, + previewHeight: number, + previewThickness: number, +) { const direction = new Vector3(end.x - start.x, 0, end.z - start.z) const length = direction.length() if (length < 0.01) { @@ -357,10 +372,10 @@ function updateWallPreview(mesh: Mesh, start: Vector3, end: Vector3) { mesh.visible = true direction.normalize() - const geometry = new BoxGeometry(length, WALL_HEIGHT, DRAFT_WALL_THICKNESS) + const geometry = new BoxGeometry(length, previewHeight, previewThickness) const angle = Math.atan2(direction.z, direction.x) - mesh.position.set((start.x + end.x) / 2, start.y + WALL_HEIGHT / 2, (start.z + end.z) / 2) + mesh.position.set((start.x + end.x) / 2, start.y + previewHeight / 2, (start.z + end.z) / 2) mesh.rotation.y = -angle if (mesh.geometry) { @@ -383,6 +398,19 @@ function getCurrentLevelWalls(): WallNode[] { export const WallTool: React.FC = () => { const unit = useViewer((state) => state.unit) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + // A placed wall preset seeds `toolDefaults.wall` (height / thickness …) + // before the tool mounts, so the draft preview is drawn at the preset's + // dimensions rather than the generic fallbacks — matching the wall that + // will be created. Read through refs so the live event handlers below see + // the latest values without re-subscribing. + const wallDefaults = useEditor((s) => s.toolDefaults.wall) + const previewHeight = typeof wallDefaults?.height === 'number' ? wallDefaults.height : WALL_HEIGHT + const previewThickness = + typeof wallDefaults?.thickness === 'number' ? wallDefaults.thickness : DRAFT_WALL_THICKNESS + const previewHeightRef = useRef(previewHeight) + previewHeightRef.current = previewHeight + const previewThicknessRef = useRef(previewThickness) + previewThicknessRef.current = previewThickness const cursorRef = useRef(null) const wallPreviewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) @@ -393,6 +421,10 @@ export const WallTool: React.FC = () => { const measurementColor = isDark ? '#ffffff' : '#111111' const measurementShadowColor = isDark ? '#111111' : '#ffffff' + // Clear preset-seeded defaults on deactivation so a later manual wall draw + // isn't built with a stale preset's parameters. Unmount-only. + useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), []) + useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] let previousWallEnd: [number, number] | null = null @@ -434,7 +466,13 @@ export const WallTool: React.FC = () => { } previousWallEnd = currentWallEnd - updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + updateWallPreview( + wallPreviewRef.current, + startingPoint.current, + endingPoint.current, + previewHeightRef.current, + previewThicknessRef.current, + ) setDraftMeasurement( getDraftMeasurementState( [startingPoint.current.x, startingPoint.current.z], @@ -442,6 +480,7 @@ export const WallTool: React.FC = () => { walls, unit, startingPoint.current.y, + previewHeightRef.current, ), ) } else { @@ -538,7 +577,7 @@ export const WallTool: React.FC = () => { return ( - + | null): void { if (keep.has(obj)) continue hideRecursive(obj, keep) } + isolationActive = true } function hideRecursive(obj: Object3D, keep: Set): void { @@ -98,4 +110,5 @@ export function clearIsolation(): void { } }) } + isolationActive = false }