From 573a4609eb3d9bb801444d8a714557958ad5b6df Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 3 Jun 2026 08:52:04 -0400 Subject: [PATCH 1/2] feat(editor): shelf placement validity box + sync tool for positioned presets Give the generic move tool a green/red footprint box for shelf placement, matching the GLB item cursor, and drop the vertical-arrow CursorSphere for shelves. Box colour comes from canPlaceOnFloor; an invalid (red) drop is refused unless Shift forces it, and R/T play the rotate sfx. Re-sync the box transform on node change so a re-armed clone isn't left at the previous rotation/position. Extract the box wireframe geometry helpers into a shared placement-box-geometry module reused by the item coordinator and the new declarative PlacementBox component. Export the Tool type so host apps can set the active tool for a positioned preset. Co-Authored-By: Claude Opus 4.8 --- .../tools/item/use-placement-coordinator.tsx | 123 +----------------- .../registry/move-registry-node-tool.tsx | 100 +++++++++++++- .../tools/shared/placement-box-geometry.ts | 123 ++++++++++++++++++ .../components/tools/shared/placement-box.tsx | 123 ++++++++++++++++++ packages/editor/src/index.tsx | 1 + 5 files changed, 352 insertions(+), 118 deletions(-) create mode 100644 packages/editor/src/components/tools/shared/placement-box-geometry.ts create mode 100644 packages/editor/src/components/tools/shared/placement-box.tsx 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 872446408..7dc4ccff1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -21,9 +21,7 @@ import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - BufferGeometry, Euler, - Float32BufferAttribute, type Group, type LineSegments, type Mesh, @@ -36,6 +34,12 @@ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' +import { + createLineGeometry, + getBoxEdgePoints, + type PreviewBounds, + updateLineGeometry, +} from '../shared/placement-box-geometry' import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math' import { ceilingStrategy, @@ -61,13 +65,6 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') { return `${Number.parseFloat(value.toFixed(2))}m` } -type PreviewBounds = { - min: [number, number, number] - max: [number, number, number] - dimensions: [number, number, number] - center: [number, number, number] -} - /** * Expand `bounds` outward so each axis is rounded up to the active grid step. * The wireframe stays centered on the original bounds centre on each axis we @@ -130,114 +127,6 @@ function getFallbackPreviewBounds( } } -function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry { - const geometry = new BufferGeometry() - geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) - return geometry -} - -function getBoxEdgePoints(bounds: PreviewBounds): number[] { - const [width, height, depth] = bounds.dimensions - const [centerX, centerY, centerZ] = bounds.center - const minX = centerX - width / 2 - const maxX = centerX + width / 2 - const minY = centerY - height / 2 - const maxY = centerY + height / 2 - const minZ = centerZ - depth / 2 - const maxZ = centerZ + depth / 2 - - return [ - minX, - minY, - minZ, - maxX, - minY, - minZ, - maxX, - minY, - minZ, - maxX, - minY, - maxZ, - maxX, - minY, - maxZ, - minX, - minY, - maxZ, - minX, - minY, - maxZ, - minX, - minY, - minZ, - - minX, - maxY, - minZ, - maxX, - maxY, - minZ, - maxX, - maxY, - minZ, - maxX, - maxY, - maxZ, - maxX, - maxY, - maxZ, - minX, - maxY, - maxZ, - minX, - maxY, - maxZ, - minX, - maxY, - minZ, - - minX, - minY, - minZ, - minX, - maxY, - minZ, - maxX, - minY, - minZ, - maxX, - maxY, - minZ, - maxX, - minY, - maxZ, - maxX, - maxY, - maxZ, - minX, - minY, - maxZ, - minX, - maxY, - maxZ, - ] -} - -function updateLineGeometry(ref: React.RefObject, points: number[]) { - const geometry = ref.current?.geometry - if (!geometry) return - - const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined - if (!attribute || attribute.array.length !== points.length) { - geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) - } else { - attribute.set(points) - attribute.needsUpdate = true - } - geometry.computeBoundingSphere() -} - // Shared materials for placement cursor - we just change colors, not swap materials // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines const edgeMaterial = new LineBasicNodeMaterial({ 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 977bb1518..1b897cf87 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 @@ -11,14 +11,17 @@ import { type NodeEvent, nodeRegistry, sceneRegistry, + spatialGridManager, useLiveTransforms, useScene, } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' +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. */ @@ -126,6 +129,26 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // and committed to the scene on drop. const rotationRef = useRef(originalRotationY) + // Shelf placement shows the same green/red footprint box GLB items use + // (instead of the vertical-arrow cursor) and refuses an invalid drop unless + // Shift forces it. The footprint comes from the kind's `floorPlaced` + // capability so this stays generic if we ever opt other kinds in. + const isShelf = node.type === 'shelf' + const boxDimensions = useMemo( + () => + isShelf + ? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? + null) + : null, + [isShelf, node], + ) + const [valid, setValid] = useState(true) + const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) + // Mirrors of `valid` / Shift 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 exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) @@ -135,8 +158,48 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { previousSnapRef.current = null hasMovedRef.current = false rotationRef.current = originalRotationY + shiftRef.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 + // fresh clone after a drop, or the user picks a different catalog tile — + // and `useState` only honours its initial value, so without this the box + // would keep the previous clone's rotation/position until the next R/T. + setCursorPosition(originalPosition) + setCursorRotationY(originalRotationY) + lastCursorRef.current = originalPosition let committed = false + // 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. + const recomputeValidity = () => { + if (!boxDimensions) return + if (shiftRef.current) { + validRef.current = true + setValid(true) + return + } + const levelId = useViewer.getState().selection.levelId ?? node.parentId + if (!levelId) { + validRef.current = true + setValid(true) + return + } + const [x, , z] = lastCursorRef.current + const { valid: placeable } = spatialGridManager.canPlaceOnFloor( + levelId, + [x, 0, z], + boxDimensions, + [0, rotationRef.current, 0], + [node.id], + ) + validRef.current = placeable + setValid(placeable) + } + recomputeValidity() + // 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 @@ -171,6 +234,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { hasMovedRef.current = true setCursorPosition([x, 0, z]) lastCursorRef.current = [x, 0, z] + recomputeValidity() // Pure imperative: move the mesh via its registered Object3D ref. sceneRegistry.nodes.get(node.id)?.position.set(x, 0, z) @@ -216,6 +280,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // 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 + // 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 const position: [number, number, number] = [...lastCursorRef.current] const rotation = toCommitRotation(rotationRef.current) @@ -276,21 +344,39 @@ 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 + recomputeValidity() + return + } 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() + sfxEmitter.emit('sfx:item-rotate') rotationRef.current += delta + setCursorRotationY(rotationRef.current) 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, }) + // Rotation changes the footprint's collision span — re-check validity. + recomputeValidity() + } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + shiftRef.current = false + recomputeValidity() + } } window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) emitter.on('grid:move', onGridMove) emitter.on('grid:click', commitAtCursor) @@ -320,6 +406,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { return () => { window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) emitter.off('grid:move', onGridMove) emitter.off('grid:click', commitAtCursor) for (const kind of CLICK_TRIGGER_KINDS) { @@ -338,7 +425,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useScene.temporal.getState().resume() } } - }, [exitMoveMode, node, originalPosition, originalRotationY]) + }, [boxDimensions, exitMoveMode, node, originalPosition, originalRotationY]) + + if (boxDimensions) { + return ( + + ) + } return } diff --git a/packages/editor/src/components/tools/shared/placement-box-geometry.ts b/packages/editor/src/components/tools/shared/placement-box-geometry.ts new file mode 100644 index 000000000..da4aa3cac --- /dev/null +++ b/packages/editor/src/components/tools/shared/placement-box-geometry.ts @@ -0,0 +1,123 @@ +import { BufferGeometry, Float32BufferAttribute, type LineSegments } from 'three' + +/** + * Axis-aligned box description shared by every placement-cursor wireframe. + * `dimensions` is the box extent on each axis; `center` is its centre in the + * cursor group's local space (so an off-centre mesh bbox stays off-centre). + * `min`/`max` are kept for callers that need the explicit corners. + */ +export type PreviewBounds = { + min: [number, number, number] + max: [number, number, number] + dimensions: [number, number, number] + center: [number, number, number] +} + +export function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) + return geometry +} + +/** Flatten a box's 12 edges into a `LineSegments` position array. */ +export function getBoxEdgePoints(bounds: PreviewBounds): number[] { + const [width, height, depth] = bounds.dimensions + const [centerX, centerY, centerZ] = bounds.center + const minX = centerX - width / 2 + const maxX = centerX + width / 2 + const minY = centerY - height / 2 + const maxY = centerY + height / 2 + const minZ = centerZ - depth / 2 + const maxZ = centerZ + depth / 2 + + return [ + minX, + minY, + minZ, + maxX, + minY, + minZ, + maxX, + minY, + minZ, + maxX, + minY, + maxZ, + maxX, + minY, + maxZ, + minX, + minY, + maxZ, + minX, + minY, + maxZ, + minX, + minY, + minZ, + + minX, + maxY, + minZ, + maxX, + maxY, + minZ, + maxX, + maxY, + minZ, + maxX, + maxY, + maxZ, + maxX, + maxY, + maxZ, + minX, + maxY, + maxZ, + minX, + maxY, + maxZ, + minX, + maxY, + minZ, + + minX, + minY, + minZ, + minX, + maxY, + minZ, + maxX, + minY, + minZ, + maxX, + maxY, + minZ, + maxX, + minY, + maxZ, + maxX, + maxY, + maxZ, + minX, + minY, + maxZ, + minX, + maxY, + maxZ, + ] +} + +export function updateLineGeometry(ref: React.RefObject, points: number[]) { + const geometry = ref.current?.geometry + if (!geometry) return + + const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined + if (!attribute || attribute.array.length !== points.length) { + geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) + } else { + attribute.set(points) + attribute.needsUpdate = true + } + geometry.computeBoundingSphere() +} diff --git a/packages/editor/src/components/tools/shared/placement-box.tsx b/packages/editor/src/components/tools/shared/placement-box.tsx new file mode 100644 index 000000000..1c0841ffb --- /dev/null +++ b/packages/editor/src/components/tools/shared/placement-box.tsx @@ -0,0 +1,123 @@ +'use client' + +import '../../../three-types' + +import { useEffect, useMemo } from 'react' +import { PlaneGeometry } from 'three' +import { distance, smoothstep, uv, vec2 } from 'three/tsl' +import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../../lib/constants' +import { createLineGeometry, getBoxEdgePoints } from './placement-box-geometry' + +const VALID_COLOR = 0x22_c5_5e // green-500 +const INVALID_COLOR = 0xef_44_44 // red-500 + +/** + * Green/red placement footprint shown while a node follows the cursor — the + * same wireframe-box + radial base plane the GLB item tool draws (geometry + * helpers shared via `placement-box-geometry`). Unlike the item coordinator's + * imperative cursor (it mutates module-singleton materials in a `useFrame` + * loop), this is a declarative, React-driven box: the caller passes the live + * `position` / `rotationY` / `valid` and the box re-renders. Its own materials + * are instanced per-mount so it never fights the item tool's singletons. + * + * The box is centred on its footprint in X/Z and sits on the floor (its base + * at the group origin's Y), so a node whose local origin is floor-level — like + * a shelf — lines up without an extra offset. + */ +export function PlacementBox({ + dimensions, + position, + rotationY = 0, + valid, +}: { + /** Footprint extent `[width, height, depth]` (unrotated). */ + dimensions: [number, number, number] + /** World-plan position of the footprint centre (floor level). */ + position: [number, number, number] + /** Y-rotation in radians, applied to the whole box. */ + rotationY?: number + /** Drives the colour: green when placeable, red otherwise. */ + valid: boolean +}) { + const [width, height, depth] = dimensions + + const edgeGeometry = useMemo( + () => + createLineGeometry( + getBoxEdgePoints({ + min: [-width / 2, 0, -depth / 2], + max: [width / 2, height, depth / 2], + dimensions: [width, height, depth], + center: [0, height / 2, 0], + }), + ), + [width, height, depth], + ) + + const basePlaneGeometry = useMemo(() => { + const geometry = new PlaneGeometry(width, depth) + geometry.rotateX(-Math.PI / 2) + geometry.translate(0, 0.01, 0) + return geometry + }, [width, depth]) + + const edgeMaterial = useMemo( + () => new LineBasicNodeMaterial({ linewidth: 3, depthTest: false, depthWrite: false }), + [], + ) + const basePlaneMaterial = useMemo(() => { + const material = new MeshBasicNodeMaterial({ + transparent: true, + depthTest: false, + depthWrite: false, + }) + // Radial opacity: transparent in the centre, opaque toward the edges — + // matches the item placement base plane. + material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) + return material + }, []) + + useEffect(() => { + const color = valid ? VALID_COLOR : INVALID_COLOR + edgeMaterial.color.setHex(color) + basePlaneMaterial.color.setHex(color) + }, [valid, edgeMaterial, basePlaneMaterial]) + + useEffect( + () => () => { + edgeGeometry.dispose() + }, + [edgeGeometry], + ) + useEffect( + () => () => { + basePlaneGeometry.dispose() + }, + [basePlaneGeometry], + ) + useEffect( + () => () => { + edgeMaterial.dispose() + basePlaneMaterial.dispose() + }, + [edgeMaterial, basePlaneMaterial], + ) + + return ( + + + + + ) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 54d22c76b..2e546ac67 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -208,6 +208,7 @@ export type { MovingFenceEndpoint, MovingWallEndpoint, SplitOrientation, + Tool, ToolDefaults, ViewMode, WorkspaceMode, From 2d7e04c4d3c4fa1a8fbc1293613b4e38def91de7 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 3 Jun 2026 09:44:22 -0400 Subject: [PATCH 2/2] chore: apply biome formatting + ignore generated next-env.d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format files that predate the current biome config (lineWidth 100, single quotes, semicolons as-needed) so they stop showing as dirty on every checkout. Formatting only — no behavior change. Also exclude **/next-env.d.ts from biome: Next regenerates it (double quotes + semicolon) on every build, so biome kept reformatting it into a perpetual dirty diff. Ignoring it lets Next own the file. Co-Authored-By: Claude Opus 4.8 --- biome.jsonc | 3 ++- packages/core/src/index.ts | 2 +- packages/core/src/schema/index.ts | 2 +- packages/core/src/services/index.ts | 2 +- .../floorplan-alignment-guide-layer.tsx | 27 ++++++++++++------- .../floorplan-registry-move-overlay.tsx | 4 +-- .../first-person/build-collider-world.ts | 5 +++- .../src/components/editor/floorplan-panel.tsx | 10 ++----- packages/nodes/src/box-vent/definition.ts | 2 +- packages/nodes/src/chimney/definition.ts | 2 +- packages/nodes/src/chimney/floorplan.ts | 7 +---- packages/nodes/src/dormer/renderer.tsx | 8 +----- packages/nodes/src/dormer/window-assembly.tsx | 6 +---- packages/nodes/src/gutter/definition.ts | 6 +---- packages/nodes/src/ridge-vent/definition.ts | 2 +- packages/nodes/src/roof/floorplan.ts | 19 ++++++------- packages/nodes/src/skylight/definition.ts | 8 ++---- 17 files changed, 48 insertions(+), 67 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index a0b121088..9b347988e 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -109,7 +109,8 @@ "!**/dist", "!**/build", "!**/public", - "!**/components/ui" + "!**/components/ui", + "!**/next-env.d.ts" ] }, "overrides": [ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 080387d16..c0b8abb0e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -88,6 +88,7 @@ export { resetSceneHistoryPauseDepth, resumeSceneHistory, } from './store/history-control' +export { default as useAlignmentGuides } from './store/use-alignment-guides' export { type ControlValue, type DoorAnimationState, @@ -106,7 +107,6 @@ export { getEffectiveNode, type LiveNodeOverrides, } from './store/use-live-node-overrides' -export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 0dd705d66..c7ec64b5d 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -52,7 +52,6 @@ export { } from './nodes/column' export { CupolaNode } from './nodes/cupola' export { DoorNode, DoorSegment } from './nodes/door' -export { EyebrowVentNode } from './nodes/eyebrow-vent' export { DormerNode, type DormerSurfaceMaterialRole, @@ -66,6 +65,7 @@ export { ElevatorNode, ElevatorShaftStyle, } from './nodes/elevator' +export { EyebrowVentNode } from './nodes/eyebrow-vent' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' export { GutterNode, GutterOutlet } from './nodes/gutter' diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index bb0cffe89..92f92e498 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -4,9 +4,9 @@ export { type AlignmentGuideAxis, type AnchorKind, bboxAnchors, - resolveAlignment, type ResolveAlignmentInput, type ResolveAlignmentResult, + resolveAlignment, } from './alignment' export { createDragSession, diff --git a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx index 5244e9f4e..6feb31e71 100644 --- a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx @@ -63,14 +63,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid return ( - + {distMeters > 1e-4 && ( @@ -123,8 +116,22 @@ function XCap({ }) { return ( - - + + ) } diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 67da92ef6..2fe972fb4 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -397,9 +397,7 @@ export function FloorplanRegistryMoveOverlay() { if (!otherId || otherId === movingNode.id) continue const b = (el as SVGGraphicsElement).getBBox() if (b.width <= 0 || b.height <= 0) continue - candidateAnchors.push( - ...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height), - ) + candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height)) } let lastSnapped: [number, number] | null = null diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 5661b48ad..4366ffbf2 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -83,7 +83,10 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() const cleanGeometry = new THREE.BufferGeometry() - cleanGeometry.setAttribute('position', toFloat32Attribute(workingGeometry.getAttribute('position'))) + cleanGeometry.setAttribute( + 'position', + toFloat32Attribute(workingGeometry.getAttribute('position')), + ) const normal = workingGeometry.getAttribute('normal') if (normal) { diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 1abbd6dd6..a020c6a81 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -7277,10 +7277,7 @@ export function FloorplanPanel() { // should stop further pointer-move processing (we either emitted a // ceiling event or are between ceilings). const handleCeilingItemPlacementMove = useCallback( - ( - planPoint: WallPlanPoint, - nativeEvent: ReactPointerEvent, - ): boolean => { + (planPoint: WallPlanPoint, nativeEvent: ReactPointerEvent): boolean => { if (!isCeilingItemPlacementActive) return false const ceiling = findCeilingAtPlanPoint(planPoint) @@ -7317,10 +7314,7 @@ export function FloorplanPanel() { // Click counterpart — used by the background placement click handler. // Returns true if the click was a valid ceiling-item placement. const handleCeilingItemPlacementClick = useCallback( - ( - planPoint: WallPlanPoint, - nativeEvent: ReactMouseEvent, - ): boolean => { + (planPoint: WallPlanPoint, nativeEvent: ReactMouseEvent): boolean => { if (!isCeilingItemPlacementActive) return false const ceiling = findCeilingAtPlanPoint(planPoint) if (!ceiling) return true diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index ff546b8ac..ba67a52cd 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -4,8 +4,8 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' -import { buildBoxVentFloorplan } from './floorplan' import { surfacePaintCapability } from '../shared/surface-paint' +import { buildBoxVentFloorplan } from './floorplan' import { boxVentParametrics } from './parametrics' import { BoxVentNode } from './schema' diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index c68c3f339..01296c644 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -1,7 +1,7 @@ import { type AnyNodeId, - type ChimneyNode as ChimneyNodeType, ChimneyNode as ChimneyNodeSchema, + type ChimneyNode as ChimneyNodeType, getActiveRoofHeight, type HandleDescriptor, type NodeDefinition, diff --git a/packages/nodes/src/chimney/floorplan.ts b/packages/nodes/src/chimney/floorplan.ts index 989c68b8c..040fca71f 100644 --- a/packages/nodes/src/chimney/floorplan.ts +++ b/packages/nodes/src/chimney/floorplan.ts @@ -183,12 +183,7 @@ export function buildChimneyFloorplan( if (node.flueShape === 'square') { children.push({ kind: 'polygon', - points: [ - toPlan(fx - r, -r), - toPlan(fx + r, -r), - toPlan(fx + r, r), - toPlan(fx - r, r), - ], + points: [toPlan(fx - r, -r), toPlan(fx + r, -r), toPlan(fx + r, r), toPlan(fx - r, r)], fill: 'none', stroke: flueStroke, strokeWidth: lineWidth * 0.8, diff --git a/packages/nodes/src/dormer/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index 01f8ab0f4..36cf6bc23 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -169,13 +169,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { ref={ref} rotation-y={node.rotation ?? 0} > - + ( - + {winGeo.glassPanes.map((pane, i) => ( [ - sign * (n.length / 2 + SIDE_HANDLE_OFFSET), - getBodyMidY(n), - getRimZ(n), - ], + position: (n) => [sign * (n.length / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), getRimZ(n)], rotationY: () => (side === 'right' ? 0 : Math.PI), }, } diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index 5c9bc649e..ec702f5f4 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -4,8 +4,8 @@ import { RidgeVentNode as RidgeVentNodeSchema, type RidgeVentNode as RidgeVentNodeType, } from '@pascal-app/core' -import { buildRidgeVentFloorplan } from './floorplan' import { surfacePaintCapability } from '../shared/surface-paint' +import { buildRidgeVentFloorplan } from './floorplan' import { ridgeVentParametrics } from './parametrics' import { RidgeVentNode } from './schema' diff --git a/packages/nodes/src/roof/floorplan.ts b/packages/nodes/src/roof/floorplan.ts index ca04284ee..234628106 100644 --- a/packages/nodes/src/roof/floorplan.ts +++ b/packages/nodes/src/roof/floorplan.ts @@ -96,7 +96,10 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { const rot = -(roof.rotation + seg.rotation) const cos = Math.cos(rot) const sin = Math.sin(rot) - const tp = (lx: number, lz: number): Pt => [segCx + lx * cos - lz * sin, segCz + lx * sin + lz * cos] + const tp = (lx: number, lz: number): Pt => [ + segCx + lx * cos - lz * sin, + segCz + lx * sin + lz * cos, + ] const hw = Math.max(seg.width, 0.01) / 2 const hd = Math.max(seg.depth, 0.01) / 2 const lw = getRoofSegmentPlanLinework(seg) @@ -110,7 +113,10 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { hips: lw.hips.map(mapSeg), breaks: lw.breaks.map(mapSeg), slope: lw.slope - ? { tail: tp(lw.slope.tail[0], lw.slope.tail[1]), head: tp(lw.slope.head[0], lw.slope.head[1]) } + ? { + tail: tp(lw.slope.tail[0], lw.slope.tail[1]), + head: tp(lw.slope.head[0], lw.slope.head[1]), + } : null, } } @@ -133,13 +139,8 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { * group is decorative (`pointerEvents: 'none'`) — clicks fall through to the * segment hit-targets. */ -export function buildRoofFloorplan( - node: RoofNode, - ctx: GeometryContext, -): FloorplanGeometry | null { - const segments = ctx.children.filter( - (c): c is RoofSegmentNode => c.type === 'roof-segment', - ) +export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): FloorplanGeometry | null { + const segments = ctx.children.filter((c): c is RoofSegmentNode => c.type === 'roof-segment') if (segments.length === 0) return null const plans = segments.map((s) => buildSegPlan(node, s)) diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index ab4bcc36c..a32a3f7e7 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -6,12 +6,12 @@ import { SkylightNode as SkylightNodeSchema, type SkylightNode as SkylightNodeType, } from '@pascal-app/core' +import { buildSkylightFloorplan } from './floorplan' import { closeSkylightOpenState, isOperableSkylightNode, toggleSkylightOpenState, } from './interaction' -import { buildSkylightFloorplan } from './floorplan' import { skylightParametrics } from './parametrics' import { buildSkylightRoofCut } from './roof-cut' import { SkylightNode } from './schema' @@ -193,11 +193,7 @@ function skylightFrameThicknessHandle(): HandleDescriptor { currentValue: (n) => n.frameThickness ?? 0.05, apply: (_n, newValue) => ({ frameThickness: newValue }), placement: { - position: (n) => [ - -(n.width / 2) - SIDE_HANDLE_OFFSET, - 0, - n.height / 2 + SIDE_HANDLE_OFFSET, - ], + position: (n) => [-(n.width / 2) - SIDE_HANDLE_OFFSET, 0, n.height / 2 + SIDE_HANDLE_OFFSET], rotationY: () => -Math.PI / 4, }, portal: 'grandparent',