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:
co-authored by
Claude Opus 4.8
parent
e741ba677a
commit
c3199e7540
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -208,6 +208,7 @@ export type {
|
||||
MovingFenceEndpoint,
|
||||
MovingWallEndpoint,
|
||||
SplitOrientation,
|
||||
Tool,
|
||||
ToolDefaults,
|
||||
ViewMode,
|
||||
WorkspaceMode,
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ChimneyNode as ChimneyNodeType,
|
||||
ChimneyNode as ChimneyNodeSchema,
|
||||
type ChimneyNode as ChimneyNodeType,
|
||||
getActiveRoofHeight,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user