viewer: Fix Dutch roof trim artifacts (#452)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* Fix spiral stair openings and fence handle arrows

* Implement roof trim planes and ridge vent clipping

* Fix mansard roof and ridge vent placement

* Fix mansard merged roof cutouts

* Fix Dutch roof gable overhang

* Refactor roof segment, ridge vent, and surface geometry

Remove Dutch ridge axis abstraction and rework roof edit system,
ridge vent clipping geometry, and roof surface placement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Simplify Dutch roof shape

* Add Dutch roof gable top geometry controls

* Fix Dutch roof slope material slots

* Render dutch roof tops as double-sided faces

* Add auto ridge vent toggle to roof segments

Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so
geometry changes only regenerate default vents when enabled, treating
legacy segments with generated vents as auto-enabled for back-compat.
Expose a panel toggle to opt in/out per segment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Snap new walls to the floor below

Feed the walls of the level directly beneath the active one into the
draft snap pipeline as extra references, so a new wall can align with
the floor below. They share the same local XZ origin, and the list is
kept separate from the current-level walls so the measurement HUD and
wall splitting only act on the active level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Set Dutch roof shape defaults on type switch

Seed the Dutch shape parameters (waist width/height/length, top rake
thickness/length) with sensible defaults whenever a segment is created
as or switched to Dutch, so the gablet is well-formed regardless of
leftover values from the previous roof type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Use green accent for corner and endpoint snap markers

Color the corner/endpoint snap markers and the vertical cursor pillar
green across the 2D floorplan beacon, the 3D alignment guide dots, and
the wall snap beacon so snap targets read as a consistent accent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add magnetic wall snapping to the roof tool

Snap roof draft corners onto wall corners, midpoints, crossings, and
bodies on the active level and the floor below, reusing the wall tool's
snap pipeline so the beacon and coloring match. The cursor's ground
dot/ring is hidden while a wall snap is active to avoid overlapping the
beacon glyph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update auto-generated Next.js route types path

Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Show cutaway outline while dragging roof trim

Slice an untrimmed segment volume generated from the live node instead of
the registry mesh, whose CSG rebuild lags a few frames behind the drag and
may still hold placeholder geometry — so the section outline now renders
deterministically. Use LineBasicNodeMaterial so the outline draws under the
WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Fill and clip roof trim cutaway, gate it to active drag

Add a violet silhouette fill behind the cutaway outline, extend the
section slicing to angled diagonal/corner trims via a generic vertical
cut plane, and clip each slice to its footprint span so the infinite
plane no longer sprouts stray lines across the rest of the roof. The
cutaway now renders only while a trim handle is being dragged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Separate and extend Dutch roof end slopes

Pull the Dutch hip end slopes out of the watertight shingle shell into
their own slab wedge so they can be reshaped independently, and extend
each end slope inward up its own hip plane until the top edge meets the
gablet's inner triangle. Refactor roof-segment shape geometry into a
shared roof-segment-shape module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Render roof trim cutaway as a material-only section cut

Replace the triangle-mesh slicer with a CSG intersection of a thin slab
against the untrimmed roof shell, so the cutaway shows red only on real
material (wall + deck bands) and leaves the hollow attic empty. Add an
analytic surface-edge outline, style both solid red like a SketchUp
section, and make the cutaway persist whenever a segment is trimmed.

Keep the merged roof shell visible during trim editing (re-trimmed live
from each segment's drag override) instead of swapping in the per-segment
meshes, whose abutting end-cap faces showed as stray white planes the
commit never had. Extend each slab past free cut-line ends only — trimmed
ends clamp to the cut line — so the red section stays inside the trim box.

Re-export INTERSECTION from the viewer CSG surface for the editor.

* Outline roof cutaway by fill silhouette, restyle to destructive red

Derive the section-cut outline from the fill geometry's edges
(EdgesGeometry) so it traces the real cut shape — wall/deck band
boundaries and the hollow-attic edge — instead of just the top surface
line. Drop the fill to 85% opacity and recolor both fill and outline to
the app's destructive red, matching the delete/destructive UI.

* Include roof accessories in trim clipping and red cutaway

Roof accessories (chimney, vents, skylight, dormer, gutter, downspout,
solar-panel, cupola) now slice at the trim plane like the roof shell and
appear in the red section-cut while dragging a trim handle:

- Export clipGeometryBySegmentTrim from the viewer as a reusable
  segment-local trim-clip primitive.
- Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh
  wrapper (nodes) that slice accessory geometry by the host segment's
  live trim override, so the cut tracks the drag.
- Wire the clip into all 11 accessory renderers, including skylight
  glass panes and dormer window glass/frame/sill.
- Feed every hosted accessory mesh into the editor's red cutaway, welding
  triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a
  cross-section.
- Register skylight in the scene-graph tree-node map so it shows in the
  outliner when placed on a roof.

Co-Authored-By: Claude <noreply@anthropic.com>

* Add smooth spline fences with editable curve handles

Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an
optional `path` (+ per-point `tangents`), selectable in a Straight/Curved
mode toggle. Selected spline fences expose draggable control-point dots
(hexagon) and symmetric tangent handles (circle) joined by a violet line,
editable in both 2D plan and 3D. Side-move arrows are dropped for splines.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix dutch roof ridge vent handling

* Fix Dutch ridge vent placement and support

* Fix Dutch roof trim artifacts

* Fix Dutch roof trim preview geometry

* Tag roof trim overlay meshes with EDITOR_LAYER

Child meshes relied on a parent group's layer, which three.js does not
propagate, so the trim section/rail/plane overlays rendered on the scene
layer — getting inked/SSGI-darkened and leaking into thumbnail exports.

Co-Authored-By: Claude <noreply@anthropic.com>

* Apply Biome cleanup

* fix(core): address Dutch roof review feedback

* chore: apply biome check cleanup

* fix(core): relax Dutch roof surface helper input

* fix

* Fix biome checks and dev verification

* fixes

* Remove unsupported Biome noShadow override

* Improve roof interactions and fence editing

* Fix fence drag and ridge vent default handling

* editor: drop wall-snap debug log, gate curved-fence finish hint on draft start

Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove
hot path.

Curved fences commit on a closing gesture (double-click / Enter) rather than
per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a
point has been placed and a curve is actually in flight. The draft point count
is published from SplineFenceDraft into a small ephemeral editor store
(useFenceCurveDraft) that the contextual helper reads, mirroring the existing
useSegmentDraftChain pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
Sudhir Yadav
2026-06-30 16:41:13 -04:00
committed by GitHub
co-authored by Claude Opus 4.8 Wassim SAMAD
parent c51e673421
commit bf25af6add
179 changed files with 10809 additions and 1467 deletions
+69 -26
View File
@@ -13,6 +13,7 @@ import {
type StairNode,
type StairSegmentNode,
sceneRegistry,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
type WallNode,
@@ -40,6 +41,38 @@ import * as THREE from 'three'
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function disableRaycastDuringDrag(root: THREE.Object3D | undefined): () => void {
if (!root) return () => {}
const originals: Array<[THREE.Object3D, THREE.Object3D['raycast']]> = []
root.traverse((child) => {
originals.push([child, child.raycast])
child.raycast = () => {}
})
return () => {
for (const [child, raycast] of originals) {
child.raycast = raycast
}
}
}
function resolvePreviewRotationY(
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode,
localRotation: number,
): number {
if ((node.type === 'roof-segment' || node.type === 'stair-segment') && node.parentId) {
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
const parentRotation =
parentNode && 'rotation' in parentNode && typeof parentNode.rotation === 'number'
? parentNode.rotation
: 0
return parentRotation + localRotation
}
return localRotation
}
export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
}> = ({ node: movingNode }) => {
@@ -59,7 +92,9 @@ export const MoveRoofTool: React.FC<{
const previousGridPosRef = useRef<[number, number] | null>(null)
const dragAnchorRef = useRef<[number, number] | null>(null)
const [previewRotation, setPreviewRotation] = useState<number>(movingNode.rotation as number)
const [previewRotation, setPreviewRotation] = useState<number>(() =>
resolvePreviewRotationY(movingNode, movingNode.rotation as number),
)
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(movingNode.id)
if (obj) {
@@ -124,24 +159,19 @@ export const MoveRoofTool: React.FC<{
movingNode.position[1],
movingNode.position[2],
]
const movingObject = sceneRegistry.nodes.get(movingNode.id)
const restoreRaycasts = disableRaycastDuringDrag(movingObject)
// For roof-segment moves: the selection was cleared before entering move mode,
// so isSelected=false on the parent roof, hiding individual segment meshes and
// showing only the merged mesh. We directly flip Three.js visibility so the
// user sees the individual segment tracking the cursor.
let segmentWrapperGroup: THREE.Object3D | null = null
let mergedRoofMesh: THREE.Object3D | null = null
if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') {
const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
if (segmentMesh?.parent) {
// segmentMesh.parent = <group visible={isSelected}> wrapper in Roof/StairRenderer
// segmentMesh.parent.parent = the registered roof/stair group
segmentWrapperGroup = segmentMesh.parent
const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof'
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null
segmentWrapperGroup.visible = true
if (mergedRoofMesh) mergedRoofMesh.visible = false
}
const syncHostedPreview = (
patch: Pick<RoofSegmentNode | StairSegmentNode, 'position' | 'rotation'>,
) => {
if (movingNode.type !== 'roof-segment' && movingNode.type !== 'stair-segment') return
useLiveNodeOverrides.getState().set(movingNode.id, patch as Record<string, unknown>)
}
const clearHostedPreview = () => {
if (movingNode.type !== 'roof-segment' && movingNode.type !== 'stair-segment') return
useLiveNodeOverrides.getState().clear(movingNode.id)
}
const resolveLevelId = () => {
@@ -360,6 +390,10 @@ export const MoveRoofTool: React.FC<{
position: lastLocalPosition,
rotation: pendingRotation,
})
syncHostedPreview({
position: lastLocalPosition,
rotation: pendingRotation,
})
}
const onGridClick = (event: GridEvent) => {
@@ -393,6 +427,7 @@ export const MoveRoofTool: React.FC<{
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [committedId] })
clearHostedPreview()
useLiveTransforms.getState().clear(movingNode.id)
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode()
@@ -406,6 +441,7 @@ export const MoveRoofTool: React.FC<{
const onCancel = () => {
wasCancelled = true
clearHostedPreview()
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
if (isNew) {
@@ -436,7 +472,7 @@ export const MoveRoofTool: React.FC<{
triggerSFX('sfx:item-rotate')
pendingRotation += rotationDelta
setPreviewRotation(pendingRotation)
setPreviewRotation(resolvePreviewRotationY(movingNode, pendingRotation))
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
@@ -457,6 +493,10 @@ export const MoveRoofTool: React.FC<{
rotation: pendingRotation,
})
}
syncHostedPreview({
position: lastLocalPosition,
rotation: pendingRotation,
})
}
}
@@ -467,11 +507,10 @@ export const MoveRoofTool: React.FC<{
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
// Restore segment wrapper visibility (React will re-sync on next render)
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
if (mergedRoofMesh) mergedRoofMesh.visible = true
restoreRaycasts()
// Clear ephemeral live transform + any alignment guides
clearHostedPreview()
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
@@ -500,10 +539,14 @@ export const MoveRoofTool: React.FC<{
}
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
// Green footprint box during whole-stair / whole-roof moves. Skipped for
// segments — their cursor lives in parent-local space and the bounding box
// would render in the wrong frame.
const showBoundingBox = movingNode.type === 'stair' || movingNode.type === 'roof'
// Show the same green drag box for both top-level roofs/stairs and their
// segments. Segment cursor positions are converted into the tool's
// building-local frame above, so the box can now ride the cursor correctly.
const showBoundingBox =
movingNode.type === 'stair' ||
movingNode.type === 'roof' ||
movingNode.type === 'roof-segment' ||
movingNode.type === 'stair-segment'
return (
<group visible={cursorVisible}>
@@ -33,7 +33,7 @@ export function buildOpeningPlacementDimensions(
ctx: GeometryContext,
): FloorplanGeometry[] {
const wall = ctx.parent as WallNode | null
if (!wall || wall.type !== 'wall') return []
if (wall?.type !== 'wall') return []
if (isCurvedWall(wall)) return []
const [x1, z1] = wall.start
@@ -161,7 +161,7 @@ function computeOutwardNormal(
let count = 0
for (const childId of levelChildren) {
const child = ctx.resolve(childId) as AnyNode | undefined
if (!child || child.type !== 'wall') continue
if (child?.type !== 'wall') continue
const w = child as WallNode
sumX += w.start[0] + w.end[0]
sumZ += w.start[1] + w.end[1]
@@ -158,7 +158,7 @@ export function planPipeRunTranslationOffsets(args: {
}
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'pipe-fitting') return null
if (partner?.type !== 'pipe-fitting') return null
const elbow = {
...(partner as PipeFittingNode),
...pipeElbowProfilePatch(profile),
@@ -568,7 +568,7 @@ function directRiserCollapseAction(args: {
)
if (!farPort) return null
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'pipe-fitting') return null
if (lower?.type !== 'pipe-fitting') return null
const lowerElbow = { ...(lower as PipeFittingNode), ...profilePatch } as PipeFittingNode
if (lowerElbow.fittingType !== 'elbow') return null
@@ -665,7 +665,7 @@ function fittingRiserAction(args: {
continue
}
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'pipe-fitting') {
if (lower?.type !== 'pipe-fitting') {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
@@ -806,7 +806,7 @@ function planVerticalOffsetsAtDy(
// shows a red preview rather than dragging the network up to absorb it).
if (conn.kind !== 'run') {
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'pipe-fitting') return { status: 'invalid' }
if (partner?.type !== 'pipe-fitting') return { status: 'invalid' }
const elbow = partner as PipeFittingNode
const profilePatch = pipeElbowProfilePatch(profile)
const profiledElbow = { ...elbow, ...profilePatch } as PipeFittingNode
@@ -0,0 +1,98 @@
import { describe, expect, test } from 'bun:test'
import { RoofSegmentNode } from '@pascal-app/core'
import { resolveRidgeSnap } from './ridge-snap'
describe('resolveRidgeSnap', () => {
test('snaps Dutch width-axis center clicks to the shortened top ridge', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
pitch: 40,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const center = resolveRidgeSnap(segment, 0, 0)
expect(center?.localX).toBeCloseTo(0)
expect(center?.localZ).toBeCloseTo(0)
expect(center?.rotation).toBeCloseTo(0)
})
test('snaps Dutch depth-axis center clicks to the shortened top ridge', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 6,
depth: 8,
pitch: 40,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const center = resolveRidgeSnap(segment, 0, 0)
expect(center?.localX).toBeCloseTo(0)
expect(center?.localZ).toBeCloseTo(0)
expect(center?.rotation).toBeCloseTo(Math.PI / 2)
})
test('snaps Dutch shoulder clicks onto the extended lower hip seam up to the rake end', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
pitch: 40,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const snap = resolveRidgeSnap(segment, 3.8, 2.8)
expect(snap).not.toBeNull()
expect(snap?.localX).toBeCloseTo(3.84, 2)
expect(snap?.localZ).toBeCloseTo(2.77, 2)
expect(Math.abs(snap?.rotation ?? 0)).toBeGreaterThan(0.1)
expect(Math.abs(Math.abs(snap?.rotation ?? 0) - Math.PI / 2)).toBeGreaterThan(0.1)
})
test('snaps mansard center clicks to the upper top ridge', () => {
const segment = RoofSegmentNode.parse({
roofType: 'mansard',
width: 8,
depth: 6,
pitch: 40,
})
const center = resolveRidgeSnap(segment, 0, 0)
expect(center?.localX).toBeCloseTo(0)
expect(center?.localZ).toBe(0)
expect(center?.rotation).toBeCloseTo(0)
})
test('snaps mansard lower-slope clicks to the nearest lower-slope vent line', () => {
const segment = RoofSegmentNode.parse({
roofType: 'mansard',
width: 8,
depth: 6,
pitch: 40,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const frontRight = resolveRidgeSnap(segment, 3.5, 2.5)
const frontLeft = resolveRidgeSnap(segment, -3.5, 2.5)
expect(frontRight?.localX).toBeGreaterThan(0)
expect(frontRight?.localZ).toBeGreaterThan(0)
expect(frontLeft?.localX).toBeLessThan(0)
expect(frontLeft?.localZ).toBeGreaterThan(0)
expect(Math.abs(frontRight?.rotation ?? 0)).toBeGreaterThan(0.1)
expect(Math.abs(frontRight?.rotation ?? 0)).toBeLessThan(Math.PI / 2 - 0.1)
})
})
+59 -20
View File
@@ -1,48 +1,87 @@
import type { RoofSegmentNode } from '@pascal-app/core'
import {
getRidgeVentLinesForSegment,
getRoofSegmentVisibleTopBounds,
type RoofSegmentNode,
} from '@pascal-app/core'
/**
* Shared ridge-line snap math for ridge-vent placement + move tools.
*
* Ridge vents must sit centered on the segment's ridge — off-ridge the
* cap's far half dips into the higher part of the slope ("goes inside"
* the roof). So the placement tools clamp the cursor onto the ridge:
* closest-point projection along the segment's local X axis, with the X
* span clipped to where a real ridge actually exists for that roof type.
* Ridge vents must sit centered on a roof break line — off-line the cap's
* far half dips into the higher part of the slope ("goes inside" the roof).
* So the placement tools clamp the cursor onto the nearest generated ridge
* line, preserving the line's yaw for hip / lower-slope runs.
*
* Per roof type (the segment's ridge runs along the segment's local X):
* - gable / gambrel / dutch / mansard: ridge spans the full width.
* Per roof type:
* - gable / gambrel: ridge spans the full width.
* - mansard: top ridge, upper hip runs, plus lower-slope runs on all
* four steep lower faces.
* - dutch: top ridge between the gablet waists plus four hip runs down
* to the eave corners (the gablet ends are vertical walls, not ridges).
* - hip: ridge is shortened by the hipped ends — spans width depth.
* A square hip (width ≤ depth) collapses to a single apex point.
* - shed: no true ridge — snap to the high eave (z = -depth/2).
* - flat: no ridge at all → return null.
*/
// Standard lift above the analytical slope surface so the cap reads as
// sitting on the shingle course rather than clipping into it. Shared
// with the renderer so live ridge-Y derivation matches placement.
export const RIDGE_LIFT = 0.12
// Ridge vents seat directly onto the analytical roof surface; any visible
// thickness belongs in the vent geometry itself, not in a renderer lift.
export const RIDGE_LIFT = 0.09
export type RidgeSnap = {
/** Segment-local X of the snapped ridge position. */
localX: number
/** Segment-local Z of the snapped ridge position (0 for peaked roofs). */
/** Segment-local Z of the snapped ridge position. */
localZ: number
/** Segment-local yaw matching the snapped ridge line. */
rotation: number
}
export function resolveRidgeSnap(
segment: RoofSegmentNode,
cursorLocalX: number,
_cursorLocalZ: number,
cursorLocalZ: number,
): RidgeSnap | null {
const roofType = segment.roofType ?? 'gable'
if (roofType === 'flat') return null
const halfW = (segment.width ?? 0) / 2
const halfD = (segment.depth ?? 0) / 2
const lines =
roofType === 'shed'
? getShedHighEaveLine(segment)
: getRidgeVentLinesForSegment(segment).map(({ start, end }) => ({ start, end }))
if (lines.length === 0) return null
const ridgeZ = roofType === 'shed' ? -halfD : 0
const ridgeHalfLength = roofType === 'hip' ? Math.max(0, halfW - halfD) : halfW
const localX = Math.max(-ridgeHalfLength, Math.min(ridgeHalfLength, cursorLocalX))
let best: RidgeSnap | null = null
let bestDistanceSq = Number.POSITIVE_INFINITY
return { localX, localZ: ridgeZ }
for (const line of lines) {
const [sx, sz] = line.start
const [ex, ez] = line.end
const dx = ex - sx
const dz = ez - sz
const lengthSq = dx * dx + dz * dz
const t =
lengthSq <= 1e-8
? 0
: Math.max(0, Math.min(1, ((cursorLocalX - sx) * dx + (cursorLocalZ - sz) * dz) / lengthSq))
const localX = sx + dx * t
const localZ = sz + dz * t
const distanceSq = (cursorLocalX - localX) ** 2 + (cursorLocalZ - localZ) ** 2
if (distanceSq < bestDistanceSq) {
bestDistanceSq = distanceSq
best = {
localX,
localZ,
rotation: Math.atan2(-dz, dx),
}
}
}
return best
}
function getShedHighEaveLine(segment: RoofSegmentNode) {
const { minX, maxX, minZ } = getRoofSegmentVisibleTopBounds(segment)
return [{ start: [minX, minZ] as [number, number], end: [maxX, minZ] as [number, number] }]
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function RoofFaceHostFrame({
[storeSegment, liveOverride],
)
if (!segment || segment.type !== 'roof-segment' || !roofFace) return null
if (segment?.type !== 'roof-segment' || !roofFace) return null
const frame = getRoofWallFaceFrame(segment, roofFace)
return (
@@ -33,7 +33,7 @@ type SceneReader = { get: (id: AnyNodeId) => unknown }
function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) {
if (!(node.roofSegmentId && node.roofFace)) return null
const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined
if (!segment || segment.type !== 'roof-segment') return null
if (segment?.type !== 'roof-segment') return null
return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) }
}
@@ -9,6 +9,7 @@ import {
import * as THREE from 'three'
const worldPoint = new THREE.Vector3()
const localPoint = new THREE.Vector3()
export type RoofSegmentHit = {
segment: RoofSegmentNode
@@ -59,7 +60,7 @@ export function resolveRoofSegmentHit(
const segObj = sceneRegistry.nodes.get(seg.id)
if (!segObj) continue
segObj.updateWorldMatrix(true, false)
const local = segObj.worldToLocal(worldPoint.clone())
const local = segObj.worldToLocal(localPoint.copy(worldPoint))
if (!firstSegment) firstSegment = { seg, segObj }
@@ -81,7 +82,7 @@ export function resolveRoofSegmentHit(
if (best) return best.hit
if (firstSegment) {
const local = firstSegment.segObj.worldToLocal(worldPoint.clone())
const local = firstSegment.segObj.worldToLocal(localPoint.copy(worldPoint))
return {
segment: firstSegment.seg,
localX: local.x,
@@ -29,6 +29,11 @@ const EQUAL_SPACING_THRESHOLD_M = 0.03
const tmp = new THREE.Vector3()
const tmpA = new THREE.Vector3()
const tmpB = new THREE.Vector3()
const ROOF_SURFACE_FOOTPRINT_CACHE_MAX = 160
const roofSurfaceFootprintCache = new Map<
string,
Pick<RoofSurfaceGuideFootprint, 'width' | 'depth'> | null
>()
export type RoofSurfaceGuideMode = 'side-center' | 'linear-edge'
@@ -80,7 +85,7 @@ export function roofSurfaceFootprintFromNode(
options?: { segment?: RoofSegmentNode },
): RoofSurfaceGuideFootprint {
const n = node as Record<string, unknown>
const geometryBounds = geometryFootprintForNode(n, options?.segment)
const geometryBounds = cachedGeometryFootprintForNode(n, options?.segment)
if (geometryBounds) {
return {
...geometryBounds,
@@ -127,6 +132,43 @@ export function roofSurfaceFootprintFromNode(
}
}
function cachedGeometryFootprintForNode(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
): Pick<RoofSurfaceGuideFootprint, 'width' | 'depth'> | null {
const key = geometryFootprintCacheKey(node, segment)
if (roofSurfaceFootprintCache.has(key)) {
const cached = roofSurfaceFootprintCache.get(key)
return cached ? { ...cached } : null
}
const footprint = geometryFootprintForNode(node, segment)
roofSurfaceFootprintCache.set(key, footprint ? { ...footprint } : null)
if (roofSurfaceFootprintCache.size > ROOF_SURFACE_FOOTPRINT_CACHE_MAX) {
const oldestKey = roofSurfaceFootprintCache.keys().next().value
if (oldestKey) roofSurfaceFootprintCache.delete(oldestKey)
}
return footprint
}
function geometryFootprintCacheKey(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
): string {
const type = typeof node.type === 'string' ? node.type : 'unknown'
const segmentKey = type === 'chimney' && segment ? `|segment:${stableCacheKey(segment)}` : ''
return `${type}|node:${stableCacheKey(node)}${segmentKey}`
}
function stableCacheKey(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map((item) => stableCacheKey(item)).join(',')}]`
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => typeof entryValue !== 'function' && entryValue !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableCacheKey(entryValue)}`).join(',')}}`
}
function geometryFootprintForNode(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
@@ -40,6 +40,17 @@ describe('getDownSlopeYaw', () => {
test('flat segment has no down-slope direction (yaw 0)', () => {
expect(getDownSlopeYaw(0, 0, fixtureSegment({ roofType: 'flat' }))).toBe(0)
})
test('dutch width-axis shoulder falls toward the side eaves', () => {
expect(getDownSlopeYaw(3.8, 0, fixtureSegment({ roofType: 'dutch' }))).toBeCloseTo(Math.PI / 2)
})
test('dutch width-axis front skirt yaws toward the front eave', () => {
expect(getDownSlopeYaw(0, 1, fixtureSegment({ roofType: 'dutch' }))).toBeCloseTo(0)
})
test('dutch depth-axis top gable falls toward the side eaves', () => {
expect(
getDownSlopeYaw(1, 0, fixtureSegment({ roofType: 'dutch', width: 6, depth: 8 })),
).toBeCloseTo(Math.PI / 2)
})
})
describe('getRoofSurfaceFaceBoundsAt', () => {
@@ -63,4 +74,30 @@ describe('getRoofSurfaceFaceBoundsAt', () => {
expect(ridgeInterval?.[0]).toBeGreaterThan(-2)
expect(ridgeInterval?.[1]).toBeLessThan(2)
})
test('mansard top surface rises to a center ridge instead of staying flat', () => {
const segment = fixtureSegment({
roofType: 'mansard',
mansardSteepWidthRatio: 0.15,
mansardSteepHeightRatio: 0.7,
})
const center = getRoofSurfaceFaceBoundsAt(segment, 0, 0).surfaceYAt(0, 0)
const offRidge = getRoofSurfaceFaceBoundsAt(segment, 0, 0.5).surfaceYAt(0, 0.5)
expect(center).toBeGreaterThan(offRidge)
})
test('dutch top surface rises from the waist to the center ridge', () => {
const segment = fixtureSegment({
roofType: 'dutch',
dutchHipWidthRatio: 0.2,
dutchHipHeightRatio: 0.6,
})
const center = getRoofSurfaceFaceBoundsAt(segment, 0, 0).surfaceYAt(0, 0)
const waist = getRoofSurfaceFaceBoundsAt(segment, 0, 1.2).surfaceYAt(0, 1.2)
expect(center).toBeGreaterThan(waist)
})
})
+150 -258
View File
@@ -1,9 +1,11 @@
import {
getRoofModuleFaces,
getRoofSegmentSurfaceY,
getRoofShapeInsets,
getRoofShapeRatios,
getSegmentSlopeFrame,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
type RoofType,
} from '@pascal-app/core'
import * as THREE from 'three'
@@ -17,6 +19,10 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe
return getRoofSegmentSurfaceY(seg, lx, lz)
}
export function getRoofTopSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
return getRoofSurfaceFaceBoundsAt(seg, lx, lz).surfaceYAt(lx, lz)
}
export type RoofSurfacePoint2D = [number, number]
export type RoofSurfaceFaceBounds = {
@@ -36,9 +42,7 @@ export function getRoofSurfaceFaceBoundsAt(
lz: number,
): RoofSurfaceFaceBounds {
const faces = getRoofSurfaceFaces(segment)
const face =
faces.find((candidate) => pointInPolygon([lx, lz], candidate.polygon)) ??
nearestFaceToPoint(faces, [lx, lz])
const face = topmostFaceAtPoint(faces, lx, lz) ?? nearestFaceToPoint(faces, [lx, lz])
const { polygon } = face
const xs = polygon.map((point) => point[0])
@@ -61,23 +65,52 @@ type RoofSurfaceFace = {
vertices: FaceVertex[]
}
type FaceVertex = { x: number; y: number; z: number }
type FaceInsets = {
iF?: number
iB?: number
iL?: number
iR?: number
dutchI?: number
}
type FaceShapeRatios = {
gambrelLowerWidthRatio: number
mansardSteepWidthRatio: number
dutchHipWidthRatio: number
}
const SHINGLE_SURFACE_EPSILON = 0.02
const FACE_TOLERANCE = 1e-6
const ROOF_SURFACE_FACE_CACHE_MAX = 128
const roofSurfaceFaceCache = new Map<string, RoofSurfaceFace[]>()
const _downSlopeYawNormal = new THREE.Vector3()
const _surfaceQuatRight = new THREE.Vector3()
const _surfaceQuatForward = new THREE.Vector3()
const _surfaceQuatMatrix = new THREE.Matrix4()
function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
const key = roofSurfaceFaceCacheKey(segment)
const cached = roofSurfaceFaceCache.get(key)
if (cached) return cached
const faces = buildRoofSurfaceFaces(segment)
roofSurfaceFaceCache.set(key, faces)
if (roofSurfaceFaceCache.size > ROOF_SURFACE_FACE_CACHE_MAX) {
const oldestKey = roofSurfaceFaceCache.keys().next().value
if (oldestKey) roofSurfaceFaceCache.delete(oldestKey)
}
return faces
}
function roofSurfaceFaceCacheKey(segment: RoofSegmentNode): string {
return [
segment.roofType,
segment.width,
segment.depth,
segment.wallHeight,
segment.wallThickness,
segment.deckThickness,
segment.overhang,
segment.shingleThickness,
segment.pitch,
segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
segment.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio,
segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio,
segment.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio,
segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
segment.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
].join('|')
}
function buildRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment
const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment)
@@ -122,39 +155,43 @@ function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
const dropTop = Math.min(1, maxDrop * 0.4)
const topBaseY = shinBotWh - dropTop
const insetsTop = getRoofFaceInsets(
const dutchHipWidthRatio = segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio
const insetsTop = getRoofShapeInsets({
roofType,
width,
depth,
shinTopWh,
topBaseY,
false,
shinTopW,
shinTopD,
wh: shinTopWh,
baseY: topBaseY,
isVoid: false,
brushW: shinTopW,
brushD: shinTopD,
tanTheta,
shingleThickness,
)
const shapeRatios = {
gambrelLowerWidthRatio:
segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
mansardSteepWidthRatio:
segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio,
dutchHipWidthRatio: segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
}
dutchHipWidthRatio,
})
const shapeRatios = getRoofShapeRatios({
gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio,
mansardSteepWidthRatio: segment.mansardSteepWidthRatio,
dutchHipWidthRatio,
dutchHipHeightRatio: segment.dutchHipHeightRatio,
dutchWaistLengthRatio: segment.dutchWaistLengthRatio,
dutchGabletRake: segment.dutchGabletRake,
})
return getRoofModuleFaces(
roofType,
shinTopW,
shinTopD,
shinTopWh,
shinTopRh,
topBaseY,
insetsTop,
width,
depth,
return getRoofModuleFaces({
type: roofType,
w: shinTopW,
d: shinTopD,
wh: shinTopWh,
rh: shinTopRh,
baseY: topBaseY,
insets: insetsTop,
baseW: width,
baseD: depth,
tanTheta,
shapeRatios,
)
dutchTopRakeThickness: segment.dutchTopRakeThickness,
})
.filter((face) => faceNormalY(face) > SHINGLE_SURFACE_EPSILON)
.map((face) => {
const vertices = face.map((point) => ({ ...point, z: point.z + transZ }))
@@ -166,199 +203,23 @@ function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
.filter((face) => face.polygon.length >= 3)
}
function getRoofFaceInsets(
roofType: RoofType,
width: number,
depth: number,
wh: number,
baseY: number,
isVoid: boolean,
brushW: number,
brushD: number,
tanTheta: number,
shingleThickness: number,
): FaceInsets {
let inset = (wh - baseY) * tanTheta
const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005
if (inset > maxSafeInset) inset = maxSafeInset
function topmostFaceAtPoint(
faces: RoofSurfaceFace[],
lx: number,
lz: number,
): RoofSurfaceFace | null {
let best: RoofSurfaceFace | null = null
let bestY = Number.NEGATIVE_INFINITY
let iF = 0
let iB = 0
let iL = 0
let iR = 0
if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') {
iF = inset
iB = inset
iL = inset
iR = inset
} else if (roofType === 'gable' || roofType === 'gambrel') {
iF = inset
iB = inset
} else if (roofType === 'shed') {
iF = inset
for (const face of faces) {
if (!pointInPolygon([lx, lz], face.polygon)) continue
const y = surfaceYOnFace(face.vertices, lx, lz)
if (y === null || y <= bestY) continue
best = face
bestY = y
}
let dutchI = Math.min(width, depth) * 0.25
if (isVoid) dutchI += shingleThickness
return { iF, iB, iL, iR, dutchI }
}
function getRoofModuleFaces(
type: RoofType,
w: number,
d: number,
wh: number,
rh: number,
baseY: number,
insets: FaceInsets,
baseW: number,
baseD: number,
tanTheta: number,
shapeRatios: FaceShapeRatios,
): FaceVertex[][] {
const v = (x: number, y: number, z: number): FaceVertex => ({ x, y, z })
const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets
const b1 = v(-w / 2 + iL, baseY, d / 2 - iF)
const b2 = v(w / 2 - iR, baseY, d / 2 - iF)
const b3 = v(w / 2 - iR, baseY, -d / 2 + iB)
const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB)
const bottom = [b4, b3, b2, b1]
const e1 = v(-w / 2, wh, d / 2)
const e2 = v(w / 2, wh, d / 2)
const e3 = v(w / 2, wh, -d / 2)
const e4 = v(-w / 2, wh, -d / 2)
const faces: FaceVertex[][] = []
faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom)
const h = wh + Math.max(0.001, rh)
if (type === 'flat' || rh === 0) {
faces.push([e1, e2, e3, e4])
} else if (type === 'gable') {
const r1 = v(-w / 2, h, 0)
const r2 = v(w / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else if (type === 'hip') {
if (Math.abs(w - d) < 0.01) {
const r = v(0, h, 0)
faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r])
} else if (w >= d) {
const r1 = v(-w / 2 + d / 2, h, 0)
const r2 = v(w / 2 - d / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else {
const r1 = v(0, h, d / 2 - w / 2)
const r2 = v(0, h, -d / 2 + w / 2)
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])
}
} else if (type === 'shed') {
const t1 = v(-w / 2, h, -d / 2)
const t2 = v(w / 2, h, -d / 2)
faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1])
} else if (type === 'gambrel') {
const mz = (baseD / 2) * shapeRatios.gambrelLowerWidthRatio
const dist = d / 2 - mz
const mh = wh + dist * (tanTheta || 0)
const m1 = v(-w / 2, mh, mz)
const m2 = v(w / 2, mh, mz)
const m3 = v(w / 2, mh, -mz)
const m4 = v(-w / 2, mh, -mz)
const r1 = v(-w / 2, h, 0)
const r2 = v(w / 2, h, 0)
faces.push(
[e4, e1, m1, r1, m4],
[e2, e3, m3, r2, m2],
[e1, e2, m2, m1],
[m1, m2, r2, r1],
[e3, e4, m4, m3],
[m3, m4, r1, r2],
)
} else if (type === 'mansard') {
const i = Math.min(baseW, baseD) * shapeRatios.mansardSteepWidthRatio
const mh = wh + i * (tanTheta || 0)
const m1 = v(-w / 2 + i, mh, d / 2 - i)
const m2 = v(w / 2 - i, mh, d / 2 - i)
const m3 = v(w / 2 - i, mh, -d / 2 + i)
const m4 = v(-w / 2 + i, mh, -d / 2 + i)
const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2)
const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2)
const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2)
const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2)
if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) {
if (w >= d) {
const r1 = v(-w / 2 + d / 2, h, 0)
const r2 = v(w / 2 - d / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else {
const r1 = v(0, h, d / 2 - w / 2)
const r2 = v(0, h, -d / 2 + w / 2)
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])
}
} else {
faces.push(
[t1, t2, t3, t4],
[e1, e2, m2, m1],
[e2, e3, m3, m2],
[e3, e4, m4, m3],
[e4, e1, m1, m4],
[m1, m2, t2, t1],
[m2, m3, t3, t2],
[m3, m4, t4, t3],
[m4, m1, t1, t4],
)
}
} else if (type === 'dutch') {
const i =
insets.dutchI !== undefined
? insets.dutchI
: Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio
const mh = wh + i * (tanTheta || 0)
if (w >= d) {
const m1 = v(-w / 2 + i, mh, d / 2 - i)
const m2 = v(w / 2 - i, mh, d / 2 - i)
const m3 = v(w / 2 - i, mh, -d / 2 + i)
const m4 = v(-w / 2 + i, mh, -d / 2 + i)
const r1 = v(-w / 2 + i, h, 0)
const r2 = v(w / 2 - i, h, 0)
faces.push(
[e1, e2, m2, m1],
[e2, e3, m3, m2],
[e3, e4, m4, m3],
[e4, e1, m1, m4],
[m4, m1, r1],
[m2, m3, r2],
[m1, m2, r2, r1],
[m3, m4, r1, r2],
)
} else {
const m1 = v(-w / 2 + i, mh, d / 2 - i)
const m2 = v(w / 2 - i, mh, d / 2 - i)
const m3 = v(w / 2 - i, mh, -d / 2 + i)
const m4 = v(-w / 2 + i, mh, -d / 2 + i)
const r1 = v(0, h, d / 2 - i)
const r2 = v(0, h, -d / 2 + i)
faces.push(
[e1, e2, m2, m1],
[e2, e3, m3, m2],
[e3, e4, m4, m3],
[e4, e1, m1, m4],
[m1, m2, r1],
[m3, m4, r2],
[m2, m3, r2, r1],
[m4, m1, r1, r2],
)
}
}
return faces
return best
}
function faceNormalY(face: FaceVertex[]): number {
@@ -527,15 +388,20 @@ function lineInterval(
// down-slope direction (cos θ horizontal + sin θ vertical). Crossing
// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz),
// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised.
function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 {
return new THREE.Vector3(dx * tan, 1, dz * tan).normalize()
function buildSlopeNormal(dx: number, dz: number, tan: number, out: THREE.Vector3): THREE.Vector3 {
return out.set(dx * tan, 1, dz * tan).normalize()
}
export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 {
export function getAnalyticalNormal(
lx: number,
lz: number,
seg: RoofSegmentNode,
out = new THREE.Vector3(),
): THREE.Vector3 {
const { roofType, depth, width } = seg
const slope = getSegmentSlopeFrame(seg)
if (slope.activeRh === 0 || slope.tanTheta === 0) {
return new THREE.Vector3(0, 1, 0)
return out.set(0, 1, 0)
}
const primaryTan = slope.tanTheta
const halfW = width / 2
@@ -557,15 +423,15 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode
const upperRise = slope.activeRh * (1 - lowerHeightRatio)
const upperRun = mz
const upperTan = upperRun > 0 ? upperRise / upperRun : 0
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan)
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan, out)
}
}
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out)
}
// Single slope falling toward +Z (ridge at -Z, eave at +Z).
if (roofType === 'shed') {
return buildSlopeNormal(0, 1, primaryTan)
return buildSlopeNormal(0, 1, primaryTan, out)
}
// 4-sided slopes: the dominant axis chooses which face the point sits
@@ -574,10 +440,9 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode
// ends and gable sides — both share the same primaryTan from the
// slope frame, so directional dispatch is enough.
if (roofType === 'hip') {
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
const onZ = (halfD > 0 ? Math.abs(lz) / halfD : 0) >= (halfW > 0 ? Math.abs(lx) / halfW : 0)
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan, out)
}
if (roofType === 'mansard') {
@@ -597,21 +462,49 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode
const topRun = Math.max(0, Math.min(halfW, halfD) - inset)
tan = topRun > 0 ? topRise / topRun : 0
}
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan)
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan, out)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan, out)
}
if (roofType === 'dutch') {
// Hip on the short-axis ends, gable on the long-axis sides. Both
// share the primary pitch on their primary (eave-band) face, so the
// approximation collapses to "pick the dominant axis."
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
const inset =
Math.min(width, depth) * (seg.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio)
const heightRatio = seg.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio
const lengthRatio = seg.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio
const lowerRise = slope.activeRh * heightRatio
if (width >= depth) {
const waistHalfX = Math.max(0, (halfW - inset) * lengthRatio)
const waistHalfZ = Math.max(0, halfD - inset)
if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) {
const topRise = slope.activeRh * (1 - heightRatio)
const topTan = waistHalfZ > 0 ? topRise / waistHalfZ : 0
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, topTan, out)
}
const xRun = Math.max(0.0001, halfW - waistHalfX)
const zRun = Math.max(0.0001, halfD - waistHalfZ)
const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0
const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0
return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize()
}
const waistHalfX = Math.max(0, halfW - inset)
const waistHalfZ = Math.max(0, (halfD - inset) * lengthRatio)
if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) {
const topRise = slope.activeRh * (1 - heightRatio)
const topTan = waistHalfX > 0 ? topRise / waistHalfX : 0
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, topTan, out)
}
const xRun = Math.max(0.0001, halfW - waistHalfX)
const zRun = Math.max(0.0001, halfD - waistHalfZ)
const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0
const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0
return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize()
}
return new THREE.Vector3(0, 1, 0)
return out.set(0, 1, 0)
}
// ─── Quaternion helper ───────────────────────────────────────────────
@@ -628,8 +521,7 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni
// depending on which slope they sit on, and registry chevrons end up
// anchored to the wrong edge. Projecting +X keeps the basis stable
// across slope-flips that share the same X axis.
const wx = new THREE.Vector3(1, 0, 0)
const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal)))
const right = _surfaceQuatRight.set(1, 0, 0).addScaledVector(normal, -normal.x)
if (right.lengthSq() < 1e-6) {
// Degenerate: normal is parallel to ±X. Fall back to +Z so the basis
// is still well-defined; this is the wall-like edge case (vertical
@@ -638,9 +530,9 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni
} else {
right.normalize()
}
const forward = new THREE.Vector3().crossVectors(right, normal).normalize()
const m = new THREE.Matrix4().makeBasis(right, normal, forward)
return out.setFromRotationMatrix(m)
_surfaceQuatForward.crossVectors(right, normal).normalize()
_surfaceQuatMatrix.makeBasis(right, normal, _surfaceQuatForward)
return out.setFromRotationMatrix(_surfaceQuatMatrix)
}
// Yaw (about the surface normal, composed AFTER `surfaceQuatFromNormal`)
@@ -650,7 +542,7 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni
// → 0, Z → π, +X → +π/2, X → −π/2. Kept next to `surfaceQuatFromNormal`
// so the two stay in lockstep — the formula is only valid for its basis.
export function getDownSlopeYaw(lx: number, lz: number, seg: RoofSegmentNode): number {
const n = getAnalyticalNormal(lx, lz, seg)
const n = getAnalyticalNormal(lx, lz, seg, _downSlopeYawNormal)
if (n.x === 0 && n.z === 0) return 0
return Math.atan2(n.x * n.y, n.z)
}
@@ -166,7 +166,7 @@ export function planRunTranslationOffsets(args: {
}
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'duct-fitting') return null
if (partner?.type !== 'duct-fitting') return null
const elbow = {
...(partner as DuctFittingNode),
...elbowProfilePatch(profile),
@@ -0,0 +1,202 @@
import {
type AnyNodeId,
normalizeRoofSegmentTrim,
type RoofSegmentNode,
type RoofSegmentTrim,
useLiveNodeOverrides,
} from '@pascal-app/core'
import { clipGeometryBySegmentTrim } from '@pascal-app/viewer'
import { useEffect, useMemo } from 'react'
import * as THREE from 'three'
import { Matrix4 } from 'three'
// Does this segment remove any material? Mirrors the viewer's internal
// `hasSegmentTrim` (not exported) — the clip is a no-op otherwise, so the
// common (untrimmed) case skips all CSG work.
function segmentHasTrim(segment: RoofSegmentNode): boolean {
const t = normalizeRoofSegmentTrim(segment)
return (
t.left > 0 ||
t.right > 0 ||
t.front > 0 ||
t.back > 0 ||
t.frontLeftX > 0 ||
t.frontLeftZ > 0 ||
t.frontRightX > 0 ||
t.frontRightZ > 0 ||
t.backLeftX > 0 ||
t.backLeftZ > 0 ||
t.backRightX > 0 ||
t.backRightZ > 0
)
}
// A stable key for the segment's trim + footprint, so the memo only re-clips
// when the cut shape actually changes (not on unrelated segment edits like
// material).
function segmentTrimKey(segment: RoofSegmentNode | undefined): string {
if (!segment) return 'none'
const t = normalizeRoofSegmentTrim(segment)
return JSON.stringify([
segment.width,
segment.depth,
t.left,
t.right,
t.front,
t.back,
t.frontLeftX,
t.frontLeftZ,
t.frontRightX,
t.frontRightZ,
t.backLeftX,
t.backLeftZ,
t.backRightX,
t.backRightZ,
])
}
/**
* Clip a roof accessory's geometry by its host segment's trim, so the part of
* the accessory standing in a trimmed-away region is sliced off exactly like
* the roof shell.
*
* `geometry` is in accessory-local space; `localToSegment` maps it into the
* segment-local frame the trim cut prisms live in (compose the same
* `node.position` + inner-group quaternion the renderer mounts the mesh with).
* The function bakes that transform, runs `clipGeometryBySegmentTrim`, then
* strips the transform back off so the returned geometry is still
* accessory-local and drops straight into the renderer's existing mesh.
*
* Returns the input geometry untouched when the segment is missing or has no
* trim — zero cost for the overwhelmingly common case. The derived (clipped)
* geometry is owned by the hook and disposed on change / unmount; the input
* geometry is never consumed (we clip a clone), so the caller keeps owning it.
*/
export function useSegmentTrimClippedGeometry(
geometry: THREE.BufferGeometry | null,
segment: RoofSegmentNode | undefined,
localToSegment: THREE.Matrix4,
): THREE.BufferGeometry | null {
// Subscribe to the segment's live trim override so the accessory re-slices
// in lockstep with the trim handle drag (the editor publishes the in-flight
// trim to `useLiveNodeOverrides`; the store only updates on commit). Without
// this the accessory would only re-clip once the drag is released.
const liveTrim = useLiveNodeOverrides((s) =>
segment ? (s.get(segment.id as AnyNodeId)?.trim as RoofSegmentTrim | undefined) : undefined,
)
const effectiveSegment = useMemo(
() => (segment && liveTrim ? { ...segment, trim: liveTrim } : segment),
[segment, liveTrim],
)
const trimKey = segmentTrimKey(effectiveSegment)
// Matrix identity isn't stable across renders; key on its elements.
const matrixKey = localToSegment.elements.join(',')
const clipped = useMemo(() => {
if (!geometry || !effectiveSegment || !segmentHasTrim(effectiveSegment)) return null
const baked = geometry.clone()
baked.applyMatrix4(localToSegment)
const result = clipGeometryBySegmentTrim(baked, effectiveSegment)
if (!result) return null
// `clipGeometryBySegmentTrim` returns the same object when there's no trim
// (already guarded above) or a fresh clone otherwise; either way it's our
// `baked` clone or its descendant, safe to mutate + own.
const inverse = new Matrix4().copy(localToSegment).invert()
result.applyMatrix4(inverse)
result.computeVertexNormals()
// CSG can stamp the freshly-exposed cut faces with the cutter's material
// slot, which may exceed the accessory's material array (multi-slot kinds:
// dormer = 5 slots, solar-panel / skylight = 2). Clamp every group back into
// the original geometry's slot range so the renderer never indexes past its
// `material` array (mismatch crashes the draw, as the empty-segment
// placeholder guard documents).
const maxSlot = geometry.groups.reduce((m, g) => Math.max(m, g.materialIndex ?? 0), 0)
for (const g of result.groups) {
if ((g.materialIndex ?? 0) > maxSlot) g.materialIndex = maxSlot
}
return result
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [geometry, localToSegment, effectiveSegment])
useEffect(() => {
return () => {
clipped?.dispose()
}
}, [clipped])
// No trim (or no input) → render the original geometry unchanged.
return clipped ?? geometry
}
const _trimMeshLocal = new THREE.Matrix4()
const _trimMeshPos = new THREE.Vector3()
const _trimMeshQuat = new THREE.Quaternion()
const _trimMeshEuler = new THREE.Euler()
const _trimMeshScale = new THREE.Vector3(1, 1, 1)
/**
* A `<mesh>` whose geometry is sliced by the host roof segment's trim, for
* accessory sub-parts that live deeper than the registered group (skylight
* glass panes, dormer window glass / frame / sill). Pass the matrix that maps
* the part's own parent frame into the segment-local frame (`parentToSegment`)
* plus the part's local `position` / `rotation`; the component composes the
* full mesh→segment transform, clips, and renders the result at the same local
* pose. When the segment has no trim the original geometry renders unchanged.
*
* `geometry` is owned by the caller (built once and reused); the clipped
* derivative is owned by the hook and disposed on change / unmount.
*/
export function TrimClippedMesh({
geometry,
segment,
parentToSegment,
position = [0, 0, 0],
rotation = [0, 0, 0],
material,
name,
castShadow,
receiveShadow,
}: {
geometry: THREE.BufferGeometry
segment: RoofSegmentNode | undefined
parentToSegment: THREE.Matrix4
position?: [number, number, number]
rotation?: [number, number, number]
material: THREE.Material | THREE.Material[]
name?: string
castShadow?: boolean
receiveShadow?: boolean
}) {
// mesh→segment = parentToSegment · T(position) · R(rotation). Keyed in the
// hook on the matrix elements, so a moving/animated pane re-clips correctly.
const localToSegment = useMemo(() => {
_trimMeshEuler.set(rotation[0], rotation[1], rotation[2])
_trimMeshQuat.setFromEuler(_trimMeshEuler)
_trimMeshPos.set(position[0], position[1], position[2])
_trimMeshLocal.compose(_trimMeshPos, _trimMeshQuat, _trimMeshScale)
return new Matrix4().multiplyMatrices(parentToSegment, _trimMeshLocal)
}, [
parentToSegment,
position[0],
position[1],
position[2],
rotation[0],
rotation[1],
rotation[2],
])
const clipped = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
return (
<mesh
castShadow={castShadow}
geometry={clipped ?? geometry}
material={material}
name={name}
position={position}
receiveShadow={receiveShadow}
rotation={rotation}
/>
)
}
+3 -3
View File
@@ -590,7 +590,7 @@ function directRiserCollapseAction(args: {
)
if (!farPort) return null
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'duct-fitting') return null
if (lower?.type !== 'duct-fitting') return null
const lowerElbow = { ...(lower as DuctFittingNode), ...profilePatch } as DuctFittingNode
if (lowerElbow.fittingType !== 'elbow') return null
@@ -687,7 +687,7 @@ function fittingRiserAction(args: {
continue
}
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'duct-fitting') {
if (lower?.type !== 'duct-fitting') {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
@@ -828,7 +828,7 @@ function planVerticalOffsetsAtDy(
// shows a red preview rather than dragging the network up to absorb it).
if (conn.kind !== 'run') {
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'duct-fitting') return { status: 'invalid' }
if (partner?.type !== 'duct-fitting') return { status: 'invalid' }
const elbow = partner as DuctFittingNode
const profilePatch = elbowProfilePatch(profile)
const profiledElbow = { ...elbow, ...profilePatch } as DuctFittingNode