Improve roof node editing

This commit is contained in:
Aymeric Rabot
2026-06-07 00:24:15 -04:00
parent b7d6038348
commit f84910848b
11 changed files with 216 additions and 72 deletions
+78 -1
View File
@@ -1,8 +1,84 @@
import { type NodeDefinition, RoofNode as RoofNodeSchema } from '@pascal-app/core'
import {
type AnyNodeId,
type HandleDescriptor,
type NodeDefinition,
RoofNode as RoofNodeSchema,
type RoofNode as RoofNodeType,
type RoofSegmentNode,
type SceneApi,
} from '@pascal-app/core'
import { buildRoofFloorplan } from './floorplan'
import { roofParametrics } from './parametrics'
import { RoofNode } from './schema'
const MOVE_FRONT_OFFSET = 0.35
const MIN_ROOF_FOOTPRINT = 1
type RoofFootprintBounds = {
maxX: number
maxZ: number
minX: number
minZ: number
}
function getRoofFootprintBounds(node: RoofNodeType, sceneApi: SceneApi): RoofFootprintBounds {
let bounds: RoofFootprintBounds | null = null
for (const childId of node.children ?? []) {
const segment = sceneApi.get<RoofSegmentNode>(childId as AnyNodeId)
if (segment?.type !== 'roof-segment') continue
const halfWidth = Math.max(segment.width, MIN_ROOF_FOOTPRINT) / 2
const halfDepth = Math.max(segment.depth, MIN_ROOF_FOOTPRINT) / 2
const cos = Math.cos(segment.rotation ?? 0)
const sin = Math.sin(segment.rotation ?? 0)
const corners = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
] as const
for (const [x, z] of corners) {
const localX = segment.position[0] + x * cos + z * sin
const localZ = segment.position[2] - x * sin + z * cos
bounds =
bounds === null
? { maxX: localX, maxZ: localZ, minX: localX, minZ: localZ }
: {
maxX: Math.max(bounds.maxX, localX),
maxZ: Math.max(bounds.maxZ, localZ),
minX: Math.min(bounds.minX, localX),
minZ: Math.min(bounds.minZ, localZ),
}
}
}
return bounds ?? { maxX: 0.5, maxZ: 0.5, minX: -0.5, minZ: -0.5 }
}
function roofMoveHandle(): HandleDescriptor<RoofNodeType> {
return {
kind: 'translate',
placement: {
position: (node, sceneApi) => {
const bounds = getRoofFootprintBounds(node, sceneApi)
return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + MOVE_FRONT_OFFSET]
},
},
apply: (_node, position) => ({ position: [position[0], position[1], position[2]] }),
snapExtents: (node, sceneApi) => {
const bounds = getRoofFootprintBounds(node, sceneApi)
const width = Math.max(bounds.maxX - bounds.minX, MIN_ROOF_FOOTPRINT)
const depth = Math.max(bounds.maxZ - bounds.minZ, MIN_ROOF_FOOTPRINT)
const swap = Math.abs(Math.sin(node.rotation ?? 0)) > 0.9
return [swap ? depth : width, swap ? width : depth]
},
}
}
const roofHandles: HandleDescriptor<RoofNodeType>[] = [roofMoveHandle()]
/**
* Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
* + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` +
@@ -43,6 +119,7 @@ export const roofDefinition: NodeDefinition<typeof RoofNode> = {
},
parametrics: roofParametrics,
handles: roofHandles,
floorplan: buildRoofFloorplan,
renderer: {
+7 -1
View File
@@ -5,6 +5,7 @@ import {
hasSegmentMaterialOverride,
type RoofNode,
type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
@@ -14,8 +15,13 @@ import * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow'
import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials'
export const RoofRenderer = ({ node }: { node: RoofNode }) => {
export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => {
const ref = useRef<THREE.Group>(null!)
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id))
const node = useMemo<RoofNode>(
() => (liveOverride ? ({ ...rawNode, ...liveOverride } as RoofNode) : rawNode),
[rawNode, liveOverride],
)
useRegistry(node.id, 'roof', ref)
useLayoutEffect(() => {
+3 -37
View File
@@ -1,6 +1,6 @@
import {
type AnyNodeId,
getActiveRoofHeight,
getRoofSegmentSurfaceY,
type RoofNode,
type RoofSegmentNode,
sceneRegistry,
@@ -17,40 +17,6 @@ export type RoofSegmentHit = {
localZ: number
}
/**
* Analytical surface Y for `seg` at segment-local (lx, lz). Mirrors
* the per-roof-type slope math in `shared/roof-surface.ts` so the
* disambiguator below stays free of cross-kind imports. Returns the
* roof's local surface height; the value is only used to compare
* candidates, never written to the scene.
*/
function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): number {
const rh = getActiveRoofHeight(seg)
const peakY = seg.wallHeight + rh
if (rh === 0) return seg.wallHeight
if (
seg.roofType === 'gable' ||
seg.roofType === 'gambrel' ||
seg.roofType === 'mansard' ||
seg.roofType === 'dutch'
) {
const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
return peakY - t * rh
}
if (seg.roofType === 'shed') {
const t = (lz + seg.depth / 2) / (seg.depth || 1)
return peakY - t * rh
}
if (seg.roofType === 'hip') {
const fx = seg.width > 0 ? Math.abs(lx) / (seg.width / 2) : 0
const fz = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
return peakY - Math.max(fx, fz) * rh
}
const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
return peakY - t * rh
}
/**
* Resolve which roof-segment the user clicked. Used by every placement
* tool that drops a new node onto a roof (box-vent, ridge-vent,
@@ -61,7 +27,7 @@ function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): numbe
* point's (x, z) lies inside *every* segment's axis-aligned half-
* extents, so a naive first-match returns the wrong slope (typically
* segments[0]). We instead score each candidate by
* `|localY analyticalSurfaceY(localX, localZ)|` and pick the
* `|localY getRoofSegmentSurfaceY(localX, localZ)|` and pick the
* smallest — the slope the user actually clicked is the one whose
* sloped surface passes through the hit point.
*
@@ -101,7 +67,7 @@ export function resolveRoofSegmentHit(
const halfW = seg.width / 2 + overhang
const halfD = seg.depth / 2 + overhang
if (Math.abs(local.x) <= halfW && Math.abs(local.z) <= halfD) {
const surfaceY = analyticalSurfaceY(seg, local.x, local.z)
const surfaceY = getRoofSegmentSurfaceY(seg, local.x, local.z)
const score = Math.abs(local.y - surfaceY)
if (!best || score < best.score) {
best = {
+2 -21
View File
@@ -1,5 +1,5 @@
import {
getActiveRoofHeight,
getRoofSegmentSurfaceY,
getSegmentSlopeFrame,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
@@ -13,26 +13,7 @@ import * as THREE from 'three'
// accessories don't reach across into a sibling kind for it.
export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
const { roofType, wallHeight, depth, width } = seg
const rh = getActiveRoofHeight(seg)
const peakY = wallHeight + rh
if (rh === 0) return wallHeight
if (roofType === 'gable') {
const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
return peakY - t * rh
}
if (roofType === 'shed') {
const t = (lz + depth / 2) / (depth || 1)
return peakY - t * rh
}
if (roofType === 'hip') {
const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0
const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
return peakY - Math.max(fx, fz) * rh
}
const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
return peakY - t * rh
return getRoofSegmentSurfaceY(seg, lx, lz)
}
// Outward normal for a roof surface tilting at angle θ in the horizontal