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
+2
-1
@@ -109,7 +109,8 @@
|
|||||||
"!**/dist",
|
"!**/dist",
|
||||||
"!**/build",
|
"!**/build",
|
||||||
"!**/public",
|
"!**/public",
|
||||||
"!**/components/ui"
|
"!**/components/ui",
|
||||||
|
"!**/next-env.d.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export {
|
|||||||
resetSceneHistoryPauseDepth,
|
resetSceneHistoryPauseDepth,
|
||||||
resumeSceneHistory,
|
resumeSceneHistory,
|
||||||
} from './store/history-control'
|
} from './store/history-control'
|
||||||
|
export { default as useAlignmentGuides } from './store/use-alignment-guides'
|
||||||
export {
|
export {
|
||||||
type ControlValue,
|
type ControlValue,
|
||||||
type DoorAnimationState,
|
type DoorAnimationState,
|
||||||
@@ -106,7 +107,6 @@ export {
|
|||||||
getEffectiveNode,
|
getEffectiveNode,
|
||||||
type LiveNodeOverrides,
|
type LiveNodeOverrides,
|
||||||
} from './store/use-live-node-overrides'
|
} 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 { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||||
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export {
|
|||||||
} from './nodes/column'
|
} from './nodes/column'
|
||||||
export { CupolaNode } from './nodes/cupola'
|
export { CupolaNode } from './nodes/cupola'
|
||||||
export { DoorNode, DoorSegment } from './nodes/door'
|
export { DoorNode, DoorSegment } from './nodes/door'
|
||||||
export { EyebrowVentNode } from './nodes/eyebrow-vent'
|
|
||||||
export {
|
export {
|
||||||
DormerNode,
|
DormerNode,
|
||||||
type DormerSurfaceMaterialRole,
|
type DormerSurfaceMaterialRole,
|
||||||
@@ -66,6 +65,7 @@ export {
|
|||||||
ElevatorNode,
|
ElevatorNode,
|
||||||
ElevatorShaftStyle,
|
ElevatorShaftStyle,
|
||||||
} from './nodes/elevator'
|
} from './nodes/elevator'
|
||||||
|
export { EyebrowVentNode } from './nodes/eyebrow-vent'
|
||||||
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||||
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
||||||
export { GutterNode, GutterOutlet } from './nodes/gutter'
|
export { GutterNode, GutterOutlet } from './nodes/gutter'
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ export {
|
|||||||
type AlignmentGuideAxis,
|
type AlignmentGuideAxis,
|
||||||
type AnchorKind,
|
type AnchorKind,
|
||||||
bboxAnchors,
|
bboxAnchors,
|
||||||
resolveAlignment,
|
|
||||||
type ResolveAlignmentInput,
|
type ResolveAlignmentInput,
|
||||||
type ResolveAlignmentResult,
|
type ResolveAlignmentResult,
|
||||||
|
resolveAlignment,
|
||||||
} from './alignment'
|
} from './alignment'
|
||||||
export {
|
export {
|
||||||
createDragSession,
|
createDragSession,
|
||||||
|
|||||||
@@ -63,14 +63,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={i}>
|
<g key={i}>
|
||||||
<line
|
<line stroke={color} strokeWidth={stroke} x1={from.x} x2={to.x} y1={from.z} y2={to.z} />
|
||||||
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={from.x} y={from.z} />
|
||||||
<XCap color={color} size={xCapSize} stroke={stroke} x={to.x} y={to.z} />
|
<XCap color={color} size={xCapSize} stroke={stroke} x={to.x} y={to.z} />
|
||||||
{distMeters > 1e-4 && (
|
{distMeters > 1e-4 && (
|
||||||
@@ -123,8 +116,22 @@ function XCap({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<line stroke={color} strokeWidth={stroke} x1={x - size} x2={x + size} y1={y - size} y2={y + size} />
|
<line
|
||||||
<line stroke={color} strokeWidth={stroke} x1={x - size} x2={x + size} y1={y + size} y2={y - size} />
|
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>
|
</g>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,9 +397,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
if (!otherId || otherId === movingNode.id) continue
|
if (!otherId || otherId === movingNode.id) continue
|
||||||
const b = (el as SVGGraphicsElement).getBBox()
|
const b = (el as SVGGraphicsElement).getBBox()
|
||||||
if (b.width <= 0 || b.height <= 0) continue
|
if (b.width <= 0 || b.height <= 0) continue
|
||||||
candidateAnchors.push(
|
candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
|
||||||
...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastSnapped: [number, number] | null = null
|
let lastSnapped: [number, number] | null = null
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
|
|||||||
? sourceGeometry.toNonIndexed()
|
? sourceGeometry.toNonIndexed()
|
||||||
: sourceGeometry.clone()
|
: sourceGeometry.clone()
|
||||||
const cleanGeometry = new THREE.BufferGeometry()
|
const cleanGeometry = new THREE.BufferGeometry()
|
||||||
cleanGeometry.setAttribute('position', toFloat32Attribute(workingGeometry.getAttribute('position')))
|
cleanGeometry.setAttribute(
|
||||||
|
'position',
|
||||||
|
toFloat32Attribute(workingGeometry.getAttribute('position')),
|
||||||
|
)
|
||||||
|
|
||||||
const normal = workingGeometry.getAttribute('normal')
|
const normal = workingGeometry.getAttribute('normal')
|
||||||
if (normal) {
|
if (normal) {
|
||||||
|
|||||||
@@ -7277,10 +7277,7 @@ export function FloorplanPanel() {
|
|||||||
// should stop further pointer-move processing (we either emitted a
|
// should stop further pointer-move processing (we either emitted a
|
||||||
// ceiling event or are between ceilings).
|
// ceiling event or are between ceilings).
|
||||||
const handleCeilingItemPlacementMove = useCallback(
|
const handleCeilingItemPlacementMove = useCallback(
|
||||||
(
|
(planPoint: WallPlanPoint, nativeEvent: ReactPointerEvent<SVGSVGElement>): boolean => {
|
||||||
planPoint: WallPlanPoint,
|
|
||||||
nativeEvent: ReactPointerEvent<SVGSVGElement>,
|
|
||||||
): boolean => {
|
|
||||||
if (!isCeilingItemPlacementActive) return false
|
if (!isCeilingItemPlacementActive) return false
|
||||||
|
|
||||||
const ceiling = findCeilingAtPlanPoint(planPoint)
|
const ceiling = findCeilingAtPlanPoint(planPoint)
|
||||||
@@ -7317,10 +7314,7 @@ export function FloorplanPanel() {
|
|||||||
// Click counterpart — used by the background placement click handler.
|
// Click counterpart — used by the background placement click handler.
|
||||||
// Returns true if the click was a valid ceiling-item placement.
|
// Returns true if the click was a valid ceiling-item placement.
|
||||||
const handleCeilingItemPlacementClick = useCallback(
|
const handleCeilingItemPlacementClick = useCallback(
|
||||||
(
|
(planPoint: WallPlanPoint, nativeEvent: ReactMouseEvent<SVGSVGElement>): boolean => {
|
||||||
planPoint: WallPlanPoint,
|
|
||||||
nativeEvent: ReactMouseEvent<SVGSVGElement>,
|
|
||||||
): boolean => {
|
|
||||||
if (!isCeilingItemPlacementActive) return false
|
if (!isCeilingItemPlacementActive) return false
|
||||||
const ceiling = findCeilingAtPlanPoint(planPoint)
|
const ceiling = findCeilingAtPlanPoint(planPoint)
|
||||||
if (!ceiling) return true
|
if (!ceiling) return true
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ import { Html } from '@react-three/drei'
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BufferGeometry,
|
|
||||||
Euler,
|
Euler,
|
||||||
Float32BufferAttribute,
|
|
||||||
type Group,
|
type Group,
|
||||||
type LineSegments,
|
type LineSegments,
|
||||||
type Mesh,
|
type Mesh,
|
||||||
@@ -36,6 +34,12 @@ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
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 { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
|
||||||
import {
|
import {
|
||||||
ceilingStrategy,
|
ceilingStrategy,
|
||||||
@@ -61,13 +65,6 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|||||||
return `${Number.parseFloat(value.toFixed(2))}m`
|
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.
|
* 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
|
* 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
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||||
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||||
const edgeMaterial = new LineBasicNodeMaterial({
|
const edgeMaterial = new LineBasicNodeMaterial({
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ import {
|
|||||||
type NodeEvent,
|
type NodeEvent,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
spatialGridManager,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
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
|
/** 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. */
|
* / 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.
|
// and committed to the scene on drop.
|
||||||
const rotationRef = useRef(originalRotationY)
|
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(() => {
|
const exitMoveMode = useCallback(() => {
|
||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -135,8 +158,48 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
previousSnapRef.current = null
|
previousSnapRef.current = null
|
||||||
hasMovedRef.current = false
|
hasMovedRef.current = false
|
||||||
rotationRef.current = originalRotationY
|
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
|
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;
|
// 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.
|
// only the Y angle changes. Most registry kinds use a `[x, y, z]` tuple.
|
||||||
const baseRotation = (node as { rotation?: unknown }).rotation
|
const baseRotation = (node as { rotation?: unknown }).rotation
|
||||||
@@ -171,6 +234,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
hasMovedRef.current = true
|
hasMovedRef.current = true
|
||||||
setCursorPosition([x, 0, z])
|
setCursorPosition([x, 0, z])
|
||||||
lastCursorRef.current = [x, 0, z]
|
lastCursorRef.current = [x, 0, z]
|
||||||
|
recomputeValidity()
|
||||||
|
|
||||||
// Pure imperative: move the mesh via its registered Object3D ref.
|
// Pure imperative: move the mesh via its registered Object3D ref.
|
||||||
sceneRegistry.nodes.get(node.id)?.position.set(x, 0, z)
|
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
|
// it's the stray trailing click of whatever armed this move, not a
|
||||||
// deliberate drop. Prevents preset re-arm from double-placing.
|
// deliberate drop. Prevents preset re-arm from double-placing.
|
||||||
if (!hasMovedRef.current) return
|
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 position: [number, number, number] = [...lastCursorRef.current]
|
||||||
|
|
||||||
const rotation = toCommitRotation(rotationRef.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
|
// item placement keys (and the "Rotate" hints the move HUD shows). Applied
|
||||||
// imperatively + mirrored to the live transform; committed on drop.
|
// imperatively + mirrored to the live transform; committed on drop.
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
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
|
if (e.metaKey || e.ctrlKey || e.altKey) return
|
||||||
let delta = 0
|
let delta = 0
|
||||||
if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP
|
if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP
|
||||||
else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
|
else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
|
||||||
else return
|
else return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
rotationRef.current += delta
|
rotationRef.current += delta
|
||||||
|
setCursorRotationY(rotationRef.current)
|
||||||
const m = sceneRegistry.nodes.get(node.id)
|
const m = sceneRegistry.nodes.get(node.id)
|
||||||
if (m) m.rotation.y = rotationRef.current
|
if (m) m.rotation.y = rotationRef.current
|
||||||
useLiveTransforms.getState().set(node.id, {
|
useLiveTransforms.getState().set(node.id, {
|
||||||
position: lastCursorRef.current,
|
position: lastCursorRef.current,
|
||||||
rotation: rotationRef.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('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', commitAtCursor)
|
emitter.on('grid:click', commitAtCursor)
|
||||||
@@ -320,6 +406,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', commitAtCursor)
|
emitter.off('grid:click', commitAtCursor)
|
||||||
for (const kind of CLICK_TRIGGER_KINDS) {
|
for (const kind of CLICK_TRIGGER_KINDS) {
|
||||||
@@ -338,7 +425,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
useScene.temporal.getState().resume()
|
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} />
|
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,
|
MovingFenceEndpoint,
|
||||||
MovingWallEndpoint,
|
MovingWallEndpoint,
|
||||||
SplitOrientation,
|
SplitOrientation,
|
||||||
|
Tool,
|
||||||
ToolDefaults,
|
ToolDefaults,
|
||||||
ViewMode,
|
ViewMode,
|
||||||
WorkspaceMode,
|
WorkspaceMode,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
type HandleDescriptor,
|
type HandleDescriptor,
|
||||||
type NodeDefinition,
|
type NodeDefinition,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { buildBoxVentFloorplan } from './floorplan'
|
|
||||||
import { surfacePaintCapability } from '../shared/surface-paint'
|
import { surfacePaintCapability } from '../shared/surface-paint'
|
||||||
|
import { buildBoxVentFloorplan } from './floorplan'
|
||||||
import { boxVentParametrics } from './parametrics'
|
import { boxVentParametrics } from './parametrics'
|
||||||
import { BoxVentNode } from './schema'
|
import { BoxVentNode } from './schema'
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type ChimneyNode as ChimneyNodeType,
|
|
||||||
ChimneyNode as ChimneyNodeSchema,
|
ChimneyNode as ChimneyNodeSchema,
|
||||||
|
type ChimneyNode as ChimneyNodeType,
|
||||||
getActiveRoofHeight,
|
getActiveRoofHeight,
|
||||||
type HandleDescriptor,
|
type HandleDescriptor,
|
||||||
type NodeDefinition,
|
type NodeDefinition,
|
||||||
|
|||||||
@@ -183,12 +183,7 @@ export function buildChimneyFloorplan(
|
|||||||
if (node.flueShape === 'square') {
|
if (node.flueShape === 'square') {
|
||||||
children.push({
|
children.push({
|
||||||
kind: 'polygon',
|
kind: 'polygon',
|
||||||
points: [
|
points: [toPlan(fx - r, -r), toPlan(fx + r, -r), toPlan(fx + r, r), toPlan(fx - r, r)],
|
||||||
toPlan(fx - r, -r),
|
|
||||||
toPlan(fx + r, -r),
|
|
||||||
toPlan(fx + r, r),
|
|
||||||
toPlan(fx - r, r),
|
|
||||||
],
|
|
||||||
fill: 'none',
|
fill: 'none',
|
||||||
stroke: flueStroke,
|
stroke: flueStroke,
|
||||||
strokeWidth: lineWidth * 0.8,
|
strokeWidth: lineWidth * 0.8,
|
||||||
|
|||||||
@@ -169,13 +169,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
rotation-y={node.rotation ?? 0}
|
rotation-y={node.rotation ?? 0}
|
||||||
>
|
>
|
||||||
<mesh
|
<mesh castShadow geometry={geometry} material={material} name="dormer-body" receiveShadow />
|
||||||
castShadow
|
|
||||||
geometry={geometry}
|
|
||||||
material={material}
|
|
||||||
name="dormer-body"
|
|
||||||
receiveShadow
|
|
||||||
/>
|
|
||||||
<DormerWindowAssembly
|
<DormerWindowAssembly
|
||||||
frameMaterial={frameSideMat}
|
frameMaterial={frameSideMat}
|
||||||
glassMaterial={glassMat}
|
glassMaterial={glassMat}
|
||||||
|
|||||||
@@ -140,11 +140,7 @@ const DormerWindowAssembly = ({
|
|||||||
// the sill always extrudes along the group's local +Z, so its position
|
// the sill always extrudes along the group's local +Z, so its position
|
||||||
// no longer needs to flip per-face.
|
// no longer needs to flip per-face.
|
||||||
const renderFace = (zPos: number, yRot: number, keyPrefix: string) => (
|
const renderFace = (zPos: number, yRot: number, keyPrefix: string) => (
|
||||||
<group
|
<group name={`dormer-window-${keyPrefix}`} position={[winX, winY, zPos]} rotation-y={yRot}>
|
||||||
name={`dormer-window-${keyPrefix}`}
|
|
||||||
position={[winX, winY, zPos]}
|
|
||||||
rotation-y={yRot}
|
|
||||||
>
|
|
||||||
{winGeo.glassPanes.map((pane, i) => (
|
{winGeo.glassPanes.map((pane, i) => (
|
||||||
<mesh
|
<mesh
|
||||||
geometry={pane.geo}
|
geometry={pane.geo}
|
||||||
|
|||||||
@@ -81,11 +81,7 @@ function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor<GutterNode
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
placement: {
|
placement: {
|
||||||
position: (n) => [
|
position: (n) => [sign * (n.length / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), getRimZ(n)],
|
||||||
sign * (n.length / 2 + SIDE_HANDLE_OFFSET),
|
|
||||||
getBodyMidY(n),
|
|
||||||
getRimZ(n),
|
|
||||||
],
|
|
||||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
RidgeVentNode as RidgeVentNodeSchema,
|
RidgeVentNode as RidgeVentNodeSchema,
|
||||||
type RidgeVentNode as RidgeVentNodeType,
|
type RidgeVentNode as RidgeVentNodeType,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { buildRidgeVentFloorplan } from './floorplan'
|
|
||||||
import { surfacePaintCapability } from '../shared/surface-paint'
|
import { surfacePaintCapability } from '../shared/surface-paint'
|
||||||
|
import { buildRidgeVentFloorplan } from './floorplan'
|
||||||
import { ridgeVentParametrics } from './parametrics'
|
import { ridgeVentParametrics } from './parametrics'
|
||||||
import { RidgeVentNode } from './schema'
|
import { RidgeVentNode } from './schema'
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan {
|
|||||||
const rot = -(roof.rotation + seg.rotation)
|
const rot = -(roof.rotation + seg.rotation)
|
||||||
const cos = Math.cos(rot)
|
const cos = Math.cos(rot)
|
||||||
const sin = Math.sin(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 hw = Math.max(seg.width, 0.01) / 2
|
||||||
const hd = Math.max(seg.depth, 0.01) / 2
|
const hd = Math.max(seg.depth, 0.01) / 2
|
||||||
const lw = getRoofSegmentPlanLinework(seg)
|
const lw = getRoofSegmentPlanLinework(seg)
|
||||||
@@ -110,7 +113,10 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan {
|
|||||||
hips: lw.hips.map(mapSeg),
|
hips: lw.hips.map(mapSeg),
|
||||||
breaks: lw.breaks.map(mapSeg),
|
breaks: lw.breaks.map(mapSeg),
|
||||||
slope: lw.slope
|
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,
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,13 +139,8 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan {
|
|||||||
* group is decorative (`pointerEvents: 'none'`) — clicks fall through to the
|
* group is decorative (`pointerEvents: 'none'`) — clicks fall through to the
|
||||||
* segment hit-targets.
|
* segment hit-targets.
|
||||||
*/
|
*/
|
||||||
export function buildRoofFloorplan(
|
export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||||
node: RoofNode,
|
const segments = ctx.children.filter((c): c is RoofSegmentNode => c.type === 'roof-segment')
|
||||||
ctx: GeometryContext,
|
|
||||||
): FloorplanGeometry | null {
|
|
||||||
const segments = ctx.children.filter(
|
|
||||||
(c): c is RoofSegmentNode => c.type === 'roof-segment',
|
|
||||||
)
|
|
||||||
if (segments.length === 0) return null
|
if (segments.length === 0) return null
|
||||||
|
|
||||||
const plans = segments.map((s) => buildSegPlan(node, s))
|
const plans = segments.map((s) => buildSegPlan(node, s))
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import {
|
|||||||
SkylightNode as SkylightNodeSchema,
|
SkylightNode as SkylightNodeSchema,
|
||||||
type SkylightNode as SkylightNodeType,
|
type SkylightNode as SkylightNodeType,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { buildSkylightFloorplan } from './floorplan'
|
||||||
import {
|
import {
|
||||||
closeSkylightOpenState,
|
closeSkylightOpenState,
|
||||||
isOperableSkylightNode,
|
isOperableSkylightNode,
|
||||||
toggleSkylightOpenState,
|
toggleSkylightOpenState,
|
||||||
} from './interaction'
|
} from './interaction'
|
||||||
import { buildSkylightFloorplan } from './floorplan'
|
|
||||||
import { skylightParametrics } from './parametrics'
|
import { skylightParametrics } from './parametrics'
|
||||||
import { buildSkylightRoofCut } from './roof-cut'
|
import { buildSkylightRoofCut } from './roof-cut'
|
||||||
import { SkylightNode } from './schema'
|
import { SkylightNode } from './schema'
|
||||||
@@ -193,11 +193,7 @@ function skylightFrameThicknessHandle(): HandleDescriptor<SkylightNodeType> {
|
|||||||
currentValue: (n) => n.frameThickness ?? 0.05,
|
currentValue: (n) => n.frameThickness ?? 0.05,
|
||||||
apply: (_n, newValue) => ({ frameThickness: newValue }),
|
apply: (_n, newValue) => ({ frameThickness: newValue }),
|
||||||
placement: {
|
placement: {
|
||||||
position: (n) => [
|
position: (n) => [-(n.width / 2) - SIDE_HANDLE_OFFSET, 0, n.height / 2 + SIDE_HANDLE_OFFSET],
|
||||||
-(n.width / 2) - SIDE_HANDLE_OFFSET,
|
|
||||||
0,
|
|
||||||
n.height / 2 + SIDE_HANDLE_OFFSET,
|
|
||||||
],
|
|
||||||
rotationY: () => -Math.PI / 4,
|
rotationY: () => -Math.PI / 4,
|
||||||
},
|
},
|
||||||
portal: 'grandparent',
|
portal: 'grandparent',
|
||||||
|
|||||||
Reference in New Issue
Block a user