editor: green/red placement box for shelf + tool sync for presets (#366)

* 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 <noreply@anthropic.com>

* chore: apply biome formatting + ignore generated next-env.d.ts

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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-03 09:52:57 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent e741ba677a
commit c3199e7540
22 changed files with 400 additions and 185 deletions
+2 -1
View File
@@ -109,7 +109,8 @@
"!**/dist",
"!**/build",
"!**/public",
"!**/components/ui"
"!**/components/ui",
"!**/next-env.d.ts"
]
},
"overrides": [
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -4,9 +4,9 @@ export {
type AlignmentGuideAxis,
type AnchorKind,
bboxAnchors,
resolveAlignment,
type ResolveAlignmentInput,
type ResolveAlignmentResult,
resolveAlignment,
} from './alignment'
export {
createDragSession,
@@ -63,14 +63,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
return (
<g key={i}>
<line
stroke={color}
strokeWidth={stroke}
x1={from.x}
x2={to.x}
y1={from.z}
y2={to.z}
/>
<line stroke={color} strokeWidth={stroke} x1={from.x} x2={to.x} y1={from.z} y2={to.z} />
<XCap color={color} size={xCapSize} stroke={stroke} x={from.x} y={from.z} />
<XCap color={color} size={xCapSize} stroke={stroke} x={to.x} y={to.z} />
{distMeters > 1e-4 && (
@@ -123,8 +116,22 @@ function XCap({
}) {
return (
<g>
<line stroke={color} strokeWidth={stroke} x1={x - size} x2={x + size} y1={y - size} y2={y + size} />
<line stroke={color} strokeWidth={stroke} x1={x - size} x2={x + size} y1={y + size} y2={y - size} />
<line
stroke={color}
strokeWidth={stroke}
x1={x - size}
x2={x + size}
y1={y - size}
y2={y + size}
/>
<line
stroke={color}
strokeWidth={stroke}
x1={x - size}
x2={x + size}
y1={y + size}
y2={y - size}
/>
</g>
)
}
@@ -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
@@ -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) {
@@ -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<SVGSVGElement>,
): boolean => {
(planPoint: WallPlanPoint, nativeEvent: ReactPointerEvent<SVGSVGElement>): 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<SVGSVGElement>,
): boolean => {
(planPoint: WallPlanPoint, nativeEvent: ReactMouseEvent<SVGSVGElement>): boolean => {
if (!isCeilingItemPlacementActive) return false
const ceiling = findCeilingAtPlanPoint(planPoint)
if (!ceiling) return true
@@ -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<LineSegments>, 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({
@@ -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 (
<PlacementBox
dimensions={boxDimensions}
position={cursorPosition}
rotationY={cursorRotationY}
valid={valid}
/>
)
}
return <CursorSphere color="#a78bfa" height={2.5} position={cursorPosition} />
}
@@ -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<LineSegments>, 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()
}
@@ -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 (
<group position={position} rotation={[0, rotationY, 0]}>
<lineSegments
geometry={edgeGeometry}
layers={EDITOR_LAYER}
material={edgeMaterial}
renderOrder={999}
/>
<mesh
geometry={basePlaneGeometry}
layers={EDITOR_LAYER}
material={basePlaneMaterial}
renderOrder={999}
/>
</group>
)
}
+1
View File
@@ -208,6 +208,7 @@ export type {
MovingFenceEndpoint,
MovingWallEndpoint,
SplitOrientation,
Tool,
ToolDefaults,
ViewMode,
WorkspaceMode,
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -1,7 +1,7 @@
import {
type AnyNodeId,
type ChimneyNode as ChimneyNodeType,
ChimneyNode as ChimneyNodeSchema,
type ChimneyNode as ChimneyNodeType,
getActiveRoofHeight,
type HandleDescriptor,
type NodeDefinition,
+1 -6
View File
@@ -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,
+1 -7
View File
@@ -169,13 +169,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
ref={ref}
rotation-y={node.rotation ?? 0}
>
<mesh
castShadow
geometry={geometry}
material={material}
name="dormer-body"
receiveShadow
/>
<mesh castShadow geometry={geometry} material={material} name="dormer-body" receiveShadow />
<DormerWindowAssembly
frameMaterial={frameSideMat}
glassMaterial={glassMat}
@@ -140,11 +140,7 @@ const DormerWindowAssembly = ({
// the sill always extrudes along the group's local +Z, so its position
// no longer needs to flip per-face.
const renderFace = (zPos: number, yRot: number, keyPrefix: string) => (
<group
name={`dormer-window-${keyPrefix}`}
position={[winX, winY, zPos]}
rotation-y={yRot}
>
<group name={`dormer-window-${keyPrefix}`} position={[winX, winY, zPos]} rotation-y={yRot}>
{winGeo.glassPanes.map((pane, i) => (
<mesh
geometry={pane.geo}
+1 -5
View File
@@ -81,11 +81,7 @@ function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor<GutterNode
}
},
placement: {
position: (n) => [
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),
},
}
+1 -1
View File
@@ -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'
+10 -9
View File
@@ -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))
+2 -6
View File
@@ -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<SkylightNodeType> {
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',