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
@@ -1,7 +1,65 @@
import { describe, expect, test } from 'bun:test'
import { getDutchRoofMetrics, getRidgeVentLinesForSegment, RoofSegmentNode } from '@pascal-app/core'
import type * as THREE from 'three'
import { getRoofTopSurfaceY } from '../../shared/roof-surface'
import { buildRidgeVentGeometry } from '../geometry'
import { RidgeVentNode } from '../schema'
function minYAtLocalPoint(geo: THREE.BufferGeometry, targetX: number, targetZ: number): number {
const pos = geo.getAttribute('position').array as Float32Array
let minY = Infinity
for (let i = 0; i < pos.length; i += 3) {
if (Math.abs(pos[i]! - targetX) <= 1e-5 && Math.abs(pos[i + 2]! - targetZ) <= 1e-5) {
minY = Math.min(minY, pos[i + 1]!)
}
}
return minY
}
function xBounds(geo: THREE.BufferGeometry): { minX: number; maxX: number } {
const pos = geo.getAttribute('position').array as Float32Array
let minX = Infinity
let maxX = -Infinity
for (let i = 0; i < pos.length; i += 3) {
minX = Math.min(minX, pos[i]!)
maxX = Math.max(maxX, pos[i]!)
}
return { minX, maxX }
}
function maxAbsZNearX(geo: THREE.BufferGeometry, targetX: number, tolerance = 0.03): number {
const pos = geo.getAttribute('position').array as Float32Array
let maxAbsZ = 0
for (let i = 0; i < pos.length; i += 3) {
if (Math.abs(pos[i]! - targetX) <= tolerance) {
maxAbsZ = Math.max(maxAbsZ, Math.abs(pos[i + 2]!))
}
}
return maxAbsZ
}
function expectFinitePositions(geo: THREE.BufferGeometry): void {
const pos = geo.getAttribute('position').array as Float32Array
for (let i = 0; i < pos.length; i++) {
expect(Number.isFinite(pos[i])).toBe(true)
}
}
function rotatedSurfaceYAt(
segment: RoofSegmentNode,
centerX: number,
centerZ: number,
rotation: number,
localX: number,
localZ: number,
): number {
return getRoofTopSurfaceY(
centerX + localX * Math.cos(rotation) + localZ * Math.sin(rotation),
centerZ - localX * Math.sin(rotation) + localZ * Math.cos(rotation),
segment,
)
}
describe('buildRidgeVentGeometry', () => {
test('returns geometry with matching position / normal / uv counts', () => {
const geo = buildRidgeVentGeometry(RidgeVentNode.parse({}))
@@ -38,14 +96,282 @@ describe('buildRidgeVentGeometry', () => {
test('length scales the X bounds proportionally', () => {
const geo = buildRidgeVentGeometry(RidgeVentNode.parse({ length: 4, endCaps: false }))
const pos = geo.getAttribute('position').array as Float32Array
let maxX = -Infinity
let minX = Infinity
for (let i = 0; i < pos.length; i += 3) {
if (pos[i]! > maxX) maxX = pos[i]!
if (pos[i]! < minX) minX = pos[i]!
}
const { minX, maxX } = xBounds(geo)
expect(maxX).toBeCloseTo(2)
expect(minX).toBeCloseTo(-2)
})
test('legacy partial ridge vents never produce NaN positions', () => {
const geo = buildRidgeVentGeometry({
id: 'rvent_legacy',
type: 'ridge-vent',
} as unknown as Parameters<typeof buildRidgeVentGeometry>[0])
expect(geo.getAttribute('position').count).toBeGreaterThan(0)
expectFinitePositions(geo)
})
test('clips rendered length to host segment trim without mutating the stored length', () => {
const segment = RoofSegmentNode.parse({
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
trim: { left: 2, right: 1 },
})
const vent = RidgeVentNode.parse({
length: 8,
position: [0, 0, 0],
rotation: 0,
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const { minX, maxX } = xBounds(geo)
expect(vent.length).toBe(8)
expect(minX).toBeCloseTo(-2)
expect(maxX).toBeCloseTo(3)
})
test('clips rendered ridge vents against diagonal trim planes', () => {
const segment = RoofSegmentNode.parse({
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
trim: { frontLeftX: 2, frontLeftZ: 4 },
})
const vent = RidgeVentNode.parse({
length: 8,
width: 0.3,
position: [0, 0, 0],
rotation: 0,
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const { minX, maxX } = xBounds(geo)
expect(vent.length).toBe(8)
expect(vent.position[0]).toBe(0)
expect(minX).toBeCloseTo(-3.575, 3)
expect(maxX).toBeCloseTo(4)
})
test('seats the underside onto rendered roof top faces when a segment is provided', () => {
const segment = RoofSegmentNode.parse({
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const vent = RidgeVentNode.parse({
width: 0.4,
height: 0.12,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const halfWidth = vent.width / 2
const ridgeY = getRoofTopSurfaceY(0, 0, segment)
expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(
getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY,
)
expect(minYAtLocalPoint(geo, -halfLength, halfWidth)).toBeCloseTo(
getRoofTopSurfaceY(-halfLength, halfWidth, segment) - ridgeY,
)
})
test('seats the underside using the vent rotation for diagonal hip caps', () => {
const segment = RoofSegmentNode.parse({
roofType: 'hip',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const rotation = Math.PI / 4
const centerX = -2.5
const centerZ = 1.5
const vent = RidgeVentNode.parse({
position: [centerX, 0, centerZ],
rotation,
width: 0.4,
height: 0.12,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const startHalfWidth = maxAbsZNearX(geo, -halfLength, 1e-5)
const ridgeY = rotatedSurfaceYAt(segment, centerX, centerZ, rotation, 0, 0)
expect(startHalfWidth).toBeGreaterThan(0)
expect(minYAtLocalPoint(geo, -halfLength, -startHalfWidth)).toBeCloseTo(
rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, -startHalfWidth) - ridgeY,
)
expect(minYAtLocalPoint(geo, -halfLength, startHalfWidth)).toBeCloseTo(
rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, startHalfWidth) - ridgeY,
)
})
test('seats diagonal hip cap ends at different roof heights along the slope', () => {
const segment = RoofSegmentNode.parse({
roofType: 'hip',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const rotation = Math.PI / 4
const centerX = -2.5
const centerZ = 1.5
const vent = RidgeVentNode.parse({
position: [centerX, 0, centerZ],
rotation,
length: Math.SQRT2,
width: 0.4,
height: 0.12,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const startHalfWidth = maxAbsZNearX(geo, -halfLength, 1e-5)
const endHalfWidth = maxAbsZNearX(geo, halfLength, 1e-5)
const ridgeY = rotatedSurfaceYAt(segment, centerX, centerZ, rotation, 0, 0)
const leftEndY =
rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, -startHalfWidth) - ridgeY
const rightEndY =
rotatedSurfaceYAt(segment, centerX, centerZ, rotation, halfLength, -endHalfWidth) - ridgeY
expect(startHalfWidth).toBeGreaterThan(0)
expect(endHalfWidth).toBeGreaterThan(0)
expect(leftEndY).not.toBeCloseTo(rightEndY)
expect(minYAtLocalPoint(geo, -halfLength, -startHalfWidth)).toBeCloseTo(leftEndY)
expect(minYAtLocalPoint(geo, halfLength, -endHalfWidth)).toBeCloseTo(rightEndY)
})
test('seats the dutch top ridge with the same sloped cap profile as other ridge vents', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const vent = RidgeVentNode.parse({
name: 'Ridge Vent',
position: [0, 0, 0],
rotation: 0,
length: 5,
width: 0.3,
height: 0.1,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const halfWidth = vent.width / 2
const ridgeY = getRoofTopSurfaceY(0, 0, segment)
const rawRoofDrop = getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY
expect(rawRoofDrop).toBeLessThan(-0.05)
expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(rawRoofDrop)
})
test('keeps an extended Dutch top ridge level through the rake span instead of drooping onto the hip', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const metrics = getDutchRoofMetrics(segment)
const rakeReach = Math.min(
segment.dutchGabletRake,
Math.max(0, segment.width / 2 - metrics.waistHalfX) * 0.98,
)
const vent = RidgeVentNode.parse({
name: 'Ridge Vent',
position: [0, 0, 0],
rotation: 0,
length: (metrics.waistHalfX + rakeReach) * 2,
width: 0.3,
height: 0.1,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const halfWidth = vent.width / 2
const ridgeY = getRoofTopSurfaceY(0, 0, segment)
const rawRoofDrop = getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY
const supportedDrop = getRoofTopSurfaceY(-metrics.waistHalfX, -halfWidth, segment) - ridgeY
expect(rawRoofDrop).toBeLessThan(supportedDrop - 0.05)
expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(supportedDrop)
expect(minYAtLocalPoint(geo, halfLength, -halfWidth)).toBeCloseTo(supportedDrop)
})
test('tapers Dutch hip ridge vent ends to a small capped nose without shortening the support-line length', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const hipLine = getRidgeVentLinesForSegment(segment).find(
(line) => line.name === 'Hip Ridge Vent' && line.start[0] < 0 && line.start[1] < 0,
)
expect(hipLine).toBeDefined()
const line = hipLine!
const rotation = Math.atan2(-(line.end[1] - line.start[1]), line.end[0] - line.start[0])
const vent = RidgeVentNode.parse({
name: 'Hip Ridge Vent',
position: [(line.start[0] + line.end[0]) / 2, 0, (line.start[1] + line.end[1]) / 2],
rotation,
length: Math.hypot(line.end[0] - line.start[0], line.end[1] - line.start[1]),
width: 0.3,
height: 0.1,
style: 'shingled',
endCaps: true,
})
const geo = buildRidgeVentGeometry(vent, segment)
const bounds = xBounds(geo)
const fullHalfLength = vent.length / 2
const startWidth = maxAbsZNearX(geo, bounds.minX)
const endWidth = maxAbsZNearX(geo, bounds.maxX)
const bodyWidth = maxAbsZNearX(geo, 0, 0.08)
expect(bounds.minX).toBeLessThanOrEqual(-fullHalfLength + 0.02)
expect(bounds.maxX).toBeGreaterThan(fullHalfLength - 0.15)
expect(startWidth).toBeGreaterThan(0.03)
expect(startWidth).toBeLessThan(0.09)
expect(endWidth).toBeGreaterThan(0.1)
expect(bodyWidth).toBeGreaterThan(startWidth + 0.015)
})
})
+2 -2
View File
@@ -152,8 +152,8 @@ const ridgeVentHandles: HandleDescriptor<RidgeVentNodeType>[] = [
* geometry builder shared with the placement preview + future tests,
* no animation or per-frame system.
*
* The placement tool snaps to the ridge (segment-local Z=0) wherever
* the cursor lands on a segment.
* The placement tool snaps to the nearest ridge/break line wherever the
* cursor lands on a segment.
*/
export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
kind: 'ridge-vent',
+2 -2
View File
@@ -29,10 +29,10 @@ export function buildRidgeVentFloorplan(
ctx: GeometryContext,
): FloorplanGeometry | null {
const segment = ctx.parent as RoofSegmentNode | null
if (!segment || segment.type !== 'roof-segment') return null
if (segment?.type !== 'roof-segment') return null
const roofId = segment.parentId as AnyNodeId | null
const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined
if (!roof || roof.type !== 'roof') return null
if (roof?.type !== 'roof') return null
const cosR = Math.cos(-roof.rotation)
const sinR = Math.sin(-roof.rotation)
+575 -76
View File
@@ -1,8 +1,46 @@
import type { RidgeVentNode } from '@pascal-app/core'
import {
getDutchRoofMetrics,
getRidgeVentLinesForSegment,
normalizeRoofSegmentTrim,
type RidgeVentNode,
type RoofSegmentNode,
} from '@pascal-app/core'
import * as THREE from 'three'
import { getRoofTopSurfaceY } from '../shared/roof-surface'
const ARC_SEGS = 16
const SHINGLED_TAB_SIZE = 0.3
const DEFAULT_RIDGE_VENT_LENGTH = 2
const DEFAULT_RIDGE_VENT_WIDTH = 0.3
const DEFAULT_RIDGE_VENT_HEIGHT = 0.1
type ProfilePoint = [z: number, capY: number]
type RidgeVentGeometryVertex = {
x: number
y: number
z: number
nx: number
ny: number
nz: number
u: number
v: number
}
type SegmentTrimClipPlane = {
signedDistance: (segmentX: number, segmentZ: number) => number
}
type RidgeVentSupportLine = {
startX: number
endX: number
name: string
taperAtStart: boolean
taperAtEnd: boolean
}
type RidgeVentEndTaper = {
taperAtStart: boolean
taperAtEnd: boolean
taperLength: number
tipHalfWidth: number
}
/**
* Pure builder for the ridge vent mesh. Each style is a peaked **band** of
@@ -22,32 +60,136 @@ const SHINGLED_TAB_SIZE = 0.3
*
* `endCaps` closes both ends. Pure: no React, no scene access, no mutation.
*/
export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometry {
const halfLen = node.length / 2
const halfW = node.width / 2
const h = node.height
export function buildRidgeVentGeometry(
node: RidgeVentNode,
segment?: RoofSegmentNode,
): THREE.BufferGeometry {
const length = finitePositive(node.length, DEFAULT_RIDGE_VENT_LENGTH)
const width = finitePositive(node.width, DEFAULT_RIDGE_VENT_WIDTH)
const h = finitePositive(node.height, DEFAULT_RIDGE_VENT_HEIGHT)
const halfLen = length / 2
const halfW = width / 2
// Band thickness. Generous enough to read as a solid cap; the eave faces
// are `t` tall, which is the depth the user actually sees from the side.
const t = Math.max(0.02, h * 0.4)
const centerX = finiteNumber(node.position?.[0], 0)
const centerZ = finiteNumber(node.position?.[2], 0)
const rotationY = finiteNumber(node.rotation, 0)
const sinR = Math.sin(rotationY)
const cosR = Math.cos(rotationY)
const dutchTopRidgeSupport = getDutchTopRidgeSupport(segment, centerX, centerZ, rotationY)
const surfaceYAt = (x: number, z: number) => {
if (!segment) return 0
let sampleX = centerX + x * cosR + z * sinR
let sampleZ = centerZ - x * sinR + z * cosR
if (dutchTopRidgeSupport) {
if (dutchTopRidgeSupport.axis === 'x') {
sampleX = clamp(
sampleX,
-dutchTopRidgeSupport.innerHalfSpan,
dutchTopRidgeSupport.innerHalfSpan,
)
} else {
sampleZ = clamp(
sampleZ,
-dutchTopRidgeSupport.innerHalfSpan,
dutchTopRidgeSupport.innerHalfSpan,
)
}
}
return getRoofTopSurfaceY(sampleX, sampleZ, segment)
}
const ridgeY = surfaceYAt(0, 0)
const seatYAt = (x: number, z: number) => (segment ? surfaceYAt(x, z) - ridgeY : 0)
const top =
node.style === 'metal'
? metalTop(halfW, h, t)
: node.style === 'shingled'
? shingledTop(halfW, h, t)
: standardTop(halfW, h, t)
const supportLine = segment ? getSupportLineForVent(segment, node) : null
const endTaper = getRidgeVentEndTaper(supportLine, width, h)
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
buildBand(positions, normals, uvs, top, t, halfLen, node.endCaps)
buildBand(positions, normals, uvs, top, seatYAt, -halfLen, halfLen, node.endCaps, endTaper)
if (node.style === 'shingled') {
addShingledTabs(positions, normals, uvs, halfLen, top, h)
addShingledTabs(positions, normals, uvs, -halfLen, halfLen, top, h, seatYAt, endTaper)
}
return buildBufferGeometry(positions, normals, uvs)
const geometry = buildBufferGeometry(positions, normals, uvs)
if (!segment) return geometry
const clipped = clipRidgeVentGeometryToSegmentTrim(geometry, node, segment)
if (clipped !== geometry) geometry.dispose()
return clipped
}
function finiteNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function finitePositive(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function getRidgeVentEndTaper(
supportLine: RidgeVentSupportLine | null,
width: number,
height: number,
): RidgeVentEndTaper | null {
if (
!supportLine ||
(supportLine.name !== 'Hip Ridge Vent' && supportLine.name !== 'Slope Ridge Vent')
) {
return null
}
const lineLength = supportLine.endX - supportLine.startX
const taperLength = Math.min(
Math.max(0.07, width * 0.45, height * 0.9),
Math.max(0, lineLength / 2 - 0.02),
)
if (!(taperLength > 0.001)) return null
return {
taperAtStart: supportLine.taperAtStart,
taperAtEnd: supportLine.taperAtEnd,
taperLength,
tipHalfWidth: Math.min(width * 0.34, Math.max(0.04, width * 0.26)),
}
}
function getDutchTopRidgeSupport(
segment: RoofSegmentNode | undefined,
centerX: number,
centerZ: number,
rotationY: number,
): { axis: 'x' | 'z'; innerHalfSpan: number } | null {
if (segment?.roofType !== 'dutch') return null
const metrics = getDutchRoofMetrics(segment)
const onWidthAxisTopRidge =
metrics.axis === 'x' && Math.abs(centerZ) <= 1e-4 && Math.abs(Math.sin(rotationY)) <= 1e-4
const onDepthAxisTopRidge =
metrics.axis === 'z' && Math.abs(centerX) <= 1e-4 && Math.abs(Math.cos(rotationY)) <= 1e-4
if (onWidthAxisTopRidge) {
return { axis: 'x', innerHalfSpan: metrics.waistHalfX }
}
if (onDepthAxisTopRidge) {
return { axis: 'z', innerHalfSpan: metrics.waistHalfZ }
}
return null
}
// ─── Top profiles (open polylines eave → peak → eave, in [z, y]) ─────────
@@ -55,8 +197,8 @@ export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometr
// eaves, seating the cap on the roof while leaving a peaked void beneath.
// Smooth rounded arch.
function standardTop(halfW: number, h: number, t: number): [number, number][] {
const pts: [number, number][] = []
function standardTop(halfW: number, h: number, t: number): ProfilePoint[] {
const pts: ProfilePoint[] = []
for (let i = 0; i <= ARC_SEGS; i++) {
const frac = i / ARC_SEGS
const z = -halfW + frac * 2 * halfW
@@ -67,7 +209,7 @@ function standardTop(halfW: number, h: number, t: number): [number, number][] {
}
// Angular peak with a narrow flat ridge at the top.
function shingledTop(halfW: number, h: number, t: number): [number, number][] {
function shingledTop(halfW: number, h: number, t: number): ProfilePoint[] {
const peakHalf = halfW * 0.12
return [
[-halfW, t],
@@ -78,7 +220,7 @@ function shingledTop(halfW: number, h: number, t: number): [number, number][] {
}
// Bent-metal cap: steep folds up to a wide flat standing seam.
function metalTop(halfW: number, h: number, t: number): [number, number][] {
function metalTop(halfW: number, h: number, t: number): ProfilePoint[] {
const seamHalf = halfW * 0.5
const shoulderY = t + (h - t) * 0.5
return [
@@ -97,78 +239,97 @@ function buildBand(
positions: number[],
normals: number[],
uvs: number[],
top: [number, number][],
t: number,
halfLen: number,
top: ProfilePoint[],
seatYAt: (x: number, z: number) => number,
startX: number,
endX: number,
withCaps: boolean,
endTaper: RidgeVentEndTaper | null = null,
): void {
const n = top.length
// Underside: the same profile dropped straight down by `t` (eaves → y 0).
const inner: [number, number][] = top.map(([z, y]) => [z, y - t])
const halfWidth = getProfileHalfWidth(top)
const stations = getRidgeVentSweepStations(startX, endX, endTaper)
const scaledZAt = (x: number, z: number): number =>
z * getProfileScaleAtX(x, startX, endX, halfWidth, endTaper)
const seatAt = (x: number, z: number): number => seatYAt(x, scaledZAt(x, z))
const topAt = (x: number, z: number, capY: number): number => seatAt(x, z) + capY
// Top surface + underside, swept along the ridge length.
for (let i = 0; i < n - 1; i++) {
const [z0, y0] = top[i]!
const [z1, y1] = top[i + 1]!
pushQuad(
positions,
normals,
uvs,
[-halfLen, y0, z0],
[halfLen, y0, z0],
[halfLen, y1, z1],
[-halfLen, y1, z1],
[0, 1, 0],
)
const [iz0, iy0] = inner[i]!
const [iz1, iy1] = inner[i + 1]!
pushQuad(
positions,
normals,
uvs,
[-halfLen, iy0, iz0],
[halfLen, iy0, iz0],
[halfLen, iy1, iz1],
[-halfLen, iy1, iz1],
[0, -1, 0],
)
for (let station = 0; station < stations.length - 1; station += 1) {
const x0 = stations[station]!
const x1 = stations[station + 1]!
for (let i = 0; i < n - 1; i++) {
const [z0, capY0] = top[i]!
const [z1, capY1] = top[i + 1]!
const x0z0 = scaledZAt(x0, z0)
const x1z0 = scaledZAt(x1, z0)
const x1z1 = scaledZAt(x1, z1)
const x0z1 = scaledZAt(x0, z1)
pushQuad(
positions,
normals,
uvs,
[x0, topAt(x0, z0, capY0), x0z0],
[x1, topAt(x1, z0, capY0), x1z0],
[x1, topAt(x1, z1, capY1), x1z1],
[x0, topAt(x0, z1, capY1), x0z1],
[0, 1, 0],
)
pushQuad(
positions,
normals,
uvs,
[x0, seatAt(x0, z0), x0z0],
[x1, seatAt(x1, z0), x1z0],
[x1, seatAt(x1, z1), x1z1],
[x0, seatAt(x0, z1), x0z1],
[0, -1, 0],
)
}
}
// Eave thickness faces (the visible depth along each long edge).
for (const idx of [0, n - 1]) {
const [z, yTop] = top[idx]!
const [, yInner] = inner[idx]!
const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1]
pushQuad(
positions,
normals,
uvs,
[-halfLen, yInner, z],
[halfLen, yInner, z],
[halfLen, yTop, z],
[-halfLen, yTop, z],
hint,
)
for (let station = 0; station < stations.length - 1; station += 1) {
const x0 = stations[station]!
const x1 = stations[station + 1]!
for (const idx of [0, n - 1]) {
const [z, capY] = top[idx]!
const x0z = scaledZAt(x0, z)
const x1z = scaledZAt(x1, z)
const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1]
pushQuad(
positions,
normals,
uvs,
[x0, seatAt(x0, z), x0z],
[x1, seatAt(x1, z), x1z],
[x1, topAt(x1, z, capY), x1z],
[x0, topAt(x0, z, capY), x0z],
hint,
)
}
}
// End caps: the band's cross-section ring at each end.
if (withCaps) {
for (const sign of [-1, 1] as const) {
const x = sign * halfLen
for (const [x, sign] of [
[startX, -1],
[endX, 1],
] as const) {
const hint: [number, number, number] = [sign, 0, 0]
for (let i = 0; i < n - 1; i++) {
const [z0, y0] = top[i]!
const [z1, y1] = top[i + 1]!
const [iz0, iy0] = inner[i]!
const [iz1, iy1] = inner[i + 1]!
const [z0, capY0] = top[i]!
const [z1, capY1] = top[i + 1]!
const scaledZ0 = scaledZAt(x, z0)
const scaledZ1 = scaledZAt(x, z1)
pushQuad(
positions,
normals,
uvs,
[x, y0, z0],
[x, y1, z1],
[x, iy1, iz1],
[x, iy0, iz0],
[x, topAt(x, z0, capY0), scaledZ0],
[x, topAt(x, z1, capY1), scaledZ1],
[x, seatAt(x, z1), scaledZ1],
[x, seatAt(x, z0), scaledZ0],
hint,
)
}
@@ -176,6 +337,45 @@ function buildBand(
}
}
function getProfileHalfWidth(top: ProfilePoint[]): number {
return top.reduce((halfWidth, [z]) => Math.max(halfWidth, Math.abs(z)), 0)
}
function getRidgeVentSweepStations(
startX: number,
endX: number,
endTaper: RidgeVentEndTaper | null,
): number[] {
const stations = [startX, endX]
if (endTaper?.taperAtStart) stations.push(startX + endTaper.taperLength)
if (endTaper?.taperAtEnd) stations.push(endX - endTaper.taperLength)
return stations
.filter((x) => x >= startX && x <= endX)
.sort((a, b) => a - b)
.filter((x, index, sorted) => index === 0 || Math.abs(x - sorted[index - 1]!) > 1e-5)
}
function getProfileScaleAtX(
x: number,
startX: number,
endX: number,
halfWidth: number,
endTaper: RidgeVentEndTaper | null,
): number {
if (!endTaper || !(halfWidth > 0.0001)) return 1
const tipScale = clamp(endTaper.tipHalfWidth / halfWidth, 0, 1)
if (endTaper.taperAtStart && x <= startX + endTaper.taperLength) {
const progress = clamp((x - startX) / endTaper.taperLength, 0, 1)
return lerp(tipScale, 1, progress)
}
if (endTaper.taperAtEnd && x >= endX - endTaper.taperLength) {
const progress = clamp((endX - x) / endTaper.taperLength, 0, 1)
return lerp(tipScale, 1, progress)
}
return 1
}
// ─── Shingled course ridges ──────────────────────────────────────────────
// Thin raised lines running across the cap at intervals, suggesting
// overlapping shingle courses. Sit on the top profile edges.
@@ -184,21 +384,27 @@ function addShingledTabs(
positions: number[],
normals: number[],
uvs: number[],
halfLen: number,
top: [number, number][],
startX: number,
endX: number,
top: ProfilePoint[],
h: number,
seatYAt: (x: number, z: number) => number,
endTaper: RidgeVentEndTaper | null = null,
): void {
const totalLen = halfLen * 2
const totalLen = endX - startX
const numTabs = Math.max(2, Math.round(totalLen / SHINGLED_TAB_SIZE))
const tabLen = totalLen / numTabs
const ridgeH = h * 0.06
const ridgeD = Math.min(0.01, tabLen * 0.15)
for (let tab = 1; tab < numTabs; tab++) {
const x = -halfLen + tab * tabLen
const x = startX + tab * tabLen
if (isInsideEndTaper(x, startX, endX, endTaper)) continue
for (let i = 0; i < top.length - 1; i++) {
const [z0, y0] = top[i]!
const [z1, y1] = top[i + 1]!
const [z0, capY0] = top[i]!
const [z1, capY1] = top[i + 1]!
const y0 = seatYAt(x, z0) + capY0
const y1 = seatYAt(x, z1) + capY1
const dz = z1 - z0
const dy = y1 - y0
const len = Math.sqrt(dz * dz + dy * dy) || 1
@@ -208,6 +414,11 @@ function addShingledTabs(
const r0z = z0 + nz * ridgeH
const r1y = y1 + ny * ridgeH
const r1z = z1 + nz * ridgeH
const backX = x - ridgeD
const by0 = seatYAt(backX, z0) + capY0
const by1 = seatYAt(backX, z1) + capY1
const br0y = by0 + ny * ridgeH
const br1y = by1 + ny * ridgeH
pushQuad(
positions,
normals,
@@ -222,16 +433,29 @@ function addShingledTabs(
positions,
normals,
uvs,
[x - ridgeD, r0y, r0z],
[x - ridgeD, r1y, r1z],
[x - ridgeD, y1, z1],
[x - ridgeD, y0, z0],
[backX, br0y, r0z],
[backX, br1y, r1z],
[backX, by1, z1],
[backX, by0, z0],
[-1, 0, 0],
)
}
}
}
function isInsideEndTaper(
x: number,
startX: number,
endX: number,
endTaper: RidgeVentEndTaper | null,
): boolean {
if (!endTaper) return false
return (
(endTaper.taperAtStart && x <= startX + endTaper.taperLength) ||
(endTaper.taperAtEnd && x >= endX - endTaper.taperLength)
)
}
// ─── Geometry plumbing ───────────────────────────────────────────────────
function buildBufferGeometry(
@@ -240,6 +464,13 @@ function buildBufferGeometry(
uvs: number[],
): THREE.BufferGeometry {
const geo = new THREE.BufferGeometry()
if (positions.length === 0) {
geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
geo.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
geo.computeBoundingSphere()
return geo
}
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
@@ -247,6 +478,274 @@ function buildBufferGeometry(
return geo
}
function clipRidgeVentGeometryToSegmentTrim(
geometry: THREE.BufferGeometry,
node: RidgeVentNode,
segment: RoofSegmentNode,
): THREE.BufferGeometry {
const planes = getSegmentTrimClipPlanes(segment)
if (planes.length === 0) return geometry
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
const uv = geometry.getAttribute('uv')
if (!position || !normal || !uv) return geometry
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
for (let i = 0; i < position.count; i += 3) {
let polygon: RidgeVentGeometryVertex[] = [
readGeometryVertex(position, normal, uv, i),
readGeometryVertex(position, normal, uv, i + 1),
readGeometryVertex(position, normal, uv, i + 2),
]
for (const plane of planes) {
polygon = clipPolygonToSegmentTrimPlane(polygon, plane, node)
if (polygon.length < 3) break
}
if (polygon.length < 3) continue
for (let j = 1; j < polygon.length - 1; j += 1) {
pushGeometryVertex(positions, normals, uvs, polygon[0]!)
pushGeometryVertex(positions, normals, uvs, polygon[j]!)
pushGeometryVertex(positions, normals, uvs, polygon[j + 1]!)
}
}
return buildBufferGeometry(positions, normals, uvs)
}
function getSupportLineForVent(
segment: RoofSegmentNode,
node: RidgeVentNode,
): RidgeVentSupportLine | null {
const lines = getRidgeVentLinesForSegment(segment)
if (lines.length === 0) return null
const centerX = finiteNumber(node.position?.[0], 0)
const centerZ = finiteNumber(node.position?.[2], 0)
const rotationY = finiteNumber(node.rotation, 0)
const dirX = Math.cos(rotationY)
const dirZ = -Math.sin(rotationY)
let best: RidgeVentSupportLine | null = null
let bestScore = Number.POSITIVE_INFINITY
for (const line of lines) {
const [sx, sz] = line.start
const [ex, ez] = line.end
const lineDx = ex - sx
const lineDz = ez - sz
const lineLength = Math.hypot(lineDx, lineDz)
if (!(lineLength > 1e-4)) continue
const unitX = lineDx / lineLength
const unitZ = lineDz / lineLength
const yawPenalty = 1 - Math.abs(unitX * dirX + unitZ * dirZ)
const centerOffsetX = centerX - sx
const centerOffsetZ = centerZ - sz
const t = Math.max(0, Math.min(lineLength, centerOffsetX * unitX + centerOffsetZ * unitZ))
const nearestX = sx + unitX * t
const nearestZ = sz + unitZ * t
const distanceSq = (centerX - nearestX) ** 2 + (centerZ - nearestZ) ** 2
const score = distanceSq + yawPenalty * 6
if (score < bestScore) {
bestScore = score
const startLocalX = (sx - centerX) * dirX + (sz - centerZ) * dirZ
const endLocalX = (ex - centerX) * dirX + (ez - centerZ) * dirZ
const startRadiusSq = sx * sx + sz * sz
const endRadiusSq = ex * ex + ez * ez
const outerIsStart = startRadiusSq > endRadiusSq
const minIsStart = startLocalX <= endLocalX
best = {
startX: Math.min(startLocalX, endLocalX),
endX: Math.max(startLocalX, endLocalX),
name: line.name,
taperAtStart: minIsStart ? outerIsStart : !outerIsStart,
taperAtEnd: minIsStart ? !outerIsStart : outerIsStart,
}
}
}
return best
}
function readGeometryVertex(
position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
normal: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
uv: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
index: number,
): RidgeVentGeometryVertex {
return {
x: position.getX(index),
y: position.getY(index),
z: position.getZ(index),
nx: normal.getX(index),
ny: normal.getY(index),
nz: normal.getZ(index),
u: uv.getX(index),
v: uv.getY(index),
}
}
function pushGeometryVertex(
positions: number[],
normals: number[],
uvs: number[],
vertex: RidgeVentGeometryVertex,
) {
positions.push(vertex.x, vertex.y, vertex.z)
normals.push(vertex.nx, vertex.ny, vertex.nz)
uvs.push(vertex.u, vertex.v)
}
function clipPolygonToSegmentTrimPlane(
polygon: RidgeVentGeometryVertex[],
plane: SegmentTrimClipPlane,
node: RidgeVentNode,
): RidgeVentGeometryVertex[] {
const next: RidgeVentGeometryVertex[] = []
let previous = polygon[polygon.length - 1]!
let previousDistance = getTrimClipDistance(previous, plane, node)
let previousInside = previousDistance <= 1e-6
for (const current of polygon) {
const currentDistance = getTrimClipDistance(current, plane, node)
const currentInside = currentDistance <= 1e-6
if (currentInside) {
if (!previousInside) {
next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance))
}
next.push(current)
} else if (previousInside) {
next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance))
}
previous = current
previousDistance = currentDistance
previousInside = currentInside
}
return next
}
function getTrimClipDistance(
vertex: RidgeVentGeometryVertex,
plane: SegmentTrimClipPlane,
node: RidgeVentNode,
): number {
const centerX = finiteNumber(node.position?.[0], 0)
const centerZ = finiteNumber(node.position?.[2], 0)
const rotationY = finiteNumber(node.rotation, 0)
const segmentX = centerX + vertex.x * Math.cos(rotationY) + vertex.z * Math.sin(rotationY)
const segmentZ = centerZ - vertex.x * Math.sin(rotationY) + vertex.z * Math.cos(rotationY)
return plane.signedDistance(segmentX, segmentZ)
}
function interpolateGeometryVertex(
a: RidgeVentGeometryVertex,
b: RidgeVentGeometryVertex,
distanceA: number,
distanceB: number,
): RidgeVentGeometryVertex {
const t = distanceA / (distanceA - distanceB || 1)
return {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
z: lerp(a.z, b.z, t),
nx: lerp(a.nx, b.nx, t),
ny: lerp(a.ny, b.ny, t),
nz: lerp(a.nz, b.nz, t),
u: lerp(a.u, b.u, t),
v: lerp(a.v, b.v, t),
}
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function getSegmentTrimClipPlanes(segment: RoofSegmentNode): SegmentTrimClipPlane[] {
const trim = normalizeRoofSegmentTrim(segment)
const planes: SegmentTrimClipPlane[] = []
const leftX = -segment.width / 2 + trim.left
const rightX = segment.width / 2 - trim.right
const frontZ = segment.depth / 2 - trim.front
const backZ = -segment.depth / 2 + trim.back
if (trim.left > 0) planes.push({ signedDistance: (x) => leftX - x })
if (trim.right > 0) planes.push({ signedDistance: (x) => x - rightX })
if (trim.front > 0) planes.push({ signedDistance: (_x, z) => z - frontZ })
if (trim.back > 0) planes.push({ signedDistance: (_x, z) => backZ - z })
const diagonalPlane = (
lineA: readonly [number, number],
lineB: readonly [number, number],
outsidePoint: readonly [number, number],
): SegmentTrimClipPlane | null => {
const dx = lineB[0] - lineA[0]
const dz = lineB[1] - lineA[1]
const length = Math.hypot(dx, dz)
if (!(length > 0)) return null
let nx = -dz / length
let nz = dx / length
const midX = (lineA[0] + lineB[0]) / 2
const midZ = (lineA[1] + lineB[1]) / 2
if (nx * (outsidePoint[0] - midX) + nz * (outsidePoint[1] - midZ) < 0) {
nx *= -1
nz *= -1
}
return {
signedDistance: (x, z) => nx * (x - midX) + nz * (z - midZ),
}
}
const pushDiagonalPlane = (
lineA: readonly [number, number],
lineB: readonly [number, number],
outsidePoint: readonly [number, number],
) => {
const plane = diagonalPlane(lineA, lineB, outsidePoint)
if (plane) planes.push(plane)
}
if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) {
pushDiagonalPlane(
[leftX + trim.frontLeftX, frontZ],
[leftX, frontZ - trim.frontLeftZ],
[leftX - 1, frontZ + 1],
)
}
if (trim.frontRightX > 0 && trim.frontRightZ > 0) {
pushDiagonalPlane(
[rightX, frontZ - trim.frontRightZ],
[rightX - trim.frontRightX, frontZ],
[rightX + 1, frontZ + 1],
)
}
if (trim.backLeftX > 0 && trim.backLeftZ > 0) {
pushDiagonalPlane(
[leftX, backZ + trim.backLeftZ],
[leftX + trim.backLeftX, backZ],
[leftX - 1, backZ - 1],
)
}
if (trim.backRightX > 0 && trim.backRightZ > 0) {
pushDiagonalPlane(
[rightX - trim.backRightX, backZ],
[rightX, backZ + trim.backRightZ],
[rightX + 1, backZ - 1],
)
}
return planes
}
// Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so
// the shared flat normal points toward `hint`. UVs are dimension-based so
// painted presets tile at world scale across the ridge length and the cap.
+14 -8
View File
@@ -23,7 +23,8 @@ import {
roofSegmentLocalToBuildingLocal,
snapRelativeRoofDragTarget,
} from '../shared/relative-roof-drag'
import { getSurfaceY } from '../shared/roof-surface'
import { resolveRidgeSnap } from '../shared/ridge-snap'
import { getRoofTopSurfaceY } from '../shared/roof-surface'
import {
clearRoofSurfacePlacementGuides,
publishRoofSurfaceNodePlacementGuides,
@@ -32,7 +33,8 @@ import RidgeVentPreview from './preview'
type RidgeVentDragTarget = Pick<RelativeRoofDragTarget, 'segment' | 'localX'> & {
localY: number
localZ: 0
localZ: number
rotation: number
}
/**
@@ -85,11 +87,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
const rawTarget = roofDrag.resolve(event)
if (!rawTarget) return null
const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true)
const snap = resolveRidgeSnap(target.segment, target.localX, target.localZ)
if (!snap) return null
const yOffset = original.position[1] ?? 0
return {
segment: target.segment,
localX: target.localX,
localY: getSurfaceY(target.localX, 0, target.segment),
localZ: 0,
localX: snap.localX,
localY: getRoofTopSurfaceY(snap.localX, snap.localZ, target.segment) + yOffset,
localZ: snap.localZ,
rotation: snap.rotation,
}
}
@@ -111,7 +117,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
lastSnap = [sx, sz]
}
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0) + target.rotation)
setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
target.localX,
@@ -158,8 +164,8 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId,
parentId: targetSegmentId,
position: [target.localX, target.localY, target.localZ],
rotation: original.rotation,
position: [target.localX, original.position[1] ?? 0, target.localZ],
rotation: target.rotation,
visible: true,
metadata: {},
})
+143 -32
View File
@@ -2,7 +2,10 @@
import {
type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial,
type RidgeVentNode,
type RoofNode,
type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry,
@@ -13,25 +16,54 @@ import {
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
getRoofMaterialArray,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap'
import { getSurfaceY } from '../shared/roof-surface'
import { getRoofTopSurfaceY } from '../shared/roof-surface'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildRidgeVentGeometry } from './geometry'
// Single white fallback for every style. Paint customisation comes from
// `node.material` / `node.materialPreset` (default: `preset-white`); the
// fallback only fires for legacy nodes that pre-date the schema default
// and shouldn't punish them with style-specific grey/metal that diverges
// from the "default white" the inspector advertises.
const defaultMaterial = new THREE.MeshStandardMaterial({
color: 0xff_ff_ff,
roughness: 0.85,
metalness: 0.1,
})
function ridgeVentSegmentGeometryKey(segment: RoofSegmentNode | undefined): string {
if (!segment) return 'none'
const trim = segment.trim
return [
segment.roofType,
segment.width,
segment.depth,
segment.wallHeight,
segment.pitch,
segment.wallThickness,
segment.deckThickness,
segment.overhang,
segment.shingleThickness,
segment.gambrelLowerWidthRatio,
segment.gambrelLowerHeightRatio,
segment.mansardSteepWidthRatio,
segment.mansardSteepHeightRatio,
segment.dutchHipWidthRatio,
segment.dutchHipHeightRatio,
trim.left,
trim.right,
trim.front,
trim.back,
trim.frontLeft,
trim.frontRight,
trim.backLeft,
trim.backRight,
trim.frontLeftX,
trim.frontLeftZ,
trim.frontRightX,
trim.frontRightZ,
trim.backLeftX,
trim.backLeftZ,
trim.backRightX,
trim.backRightZ,
].join('|')
}
/**
* Ridge vent renderer. Sits along the ridge of a roof-segment — no
@@ -66,6 +98,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
const node: RidgeVentNode = overrides
? ({ ...storeNode, ...overrides } as RidgeVentNode)
: storeNode
const nodePosition = node.position ?? [0, 0, 0]
const segmentStore = useScene((state) =>
node.roofSegmentId
@@ -87,11 +120,44 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
? ({ ...segmentStore, ...segmentOverrides } as RoofSegmentNode)
: segmentStore
: undefined
const parentRoof = useScene((state) =>
segmentStore?.parentId
? (state.nodes[segmentStore.parentId as AnyNodeId] as RoofNode | undefined)
: undefined,
)
const segmentGeometryKey = ridgeVentSegmentGeometryKey(segment)
const rotationY = node.rotation ?? 0
const snap = useMemo(
() =>
segment && Math.abs(rotationY) < 1e-5
? resolveRidgeSnap(segment, nodePosition[0] ?? 0, nodePosition[2] ?? 0)
: null,
[segment, rotationY, nodePosition[0], nodePosition[2]],
)
const ridgeX = snap ? snap.localX : (nodePosition[0] ?? 0)
const ridgeZ = snap ? snap.localZ : (nodePosition[2] ?? 0)
const effectiveNode = useMemo<RidgeVentNode>(
() => ({
...node,
position: [ridgeX, nodePosition[1] ?? 0, ridgeZ],
}),
[node, ridgeX, ridgeZ, nodePosition[1]],
)
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: `segmentGeometryKey` captures the segment fields that affect the generated mesh; depending on the whole segment would rebuild on unrelated node changes.
const geometry = useMemo(
() => buildRidgeVentGeometry(node),
[node.length, node.width, node.height, node.style, node.endCaps],
() => buildRidgeVentGeometry(effectiveNode, segment),
[
effectiveNode.length,
effectiveNode.width,
effectiveNode.height,
effectiveNode.style,
effectiveNode.endCaps,
effectiveNode.rotation,
effectiveNode.position[0],
effectiveNode.position[2],
segmentGeometryKey,
],
)
useEffect(() => () => geometry.dispose(), [geometry])
@@ -104,13 +170,62 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
// closed solid in `geometry.ts` is the right fix if the underside-view
// becomes noticeable.
const material = useMemo(() => {
if (!textures || (!node.material && !node.materialPreset)) {
return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
const createDefaultTopMaterial = () => {
const parentSpec = parentRoof ? getEffectiveRoofSurfaceMaterial(parentRoof, 'top') : undefined
const spec = segment ? getEffectiveSegmentSurfaceMaterial(segment, 'top', parentSpec) : null
if (typeof spec?.materialPreset === 'string') {
const resolved = createMaterialFromPresetRef(spec.materialPreset, shading)
if (resolved) return resolved
}
if (spec?.material !== undefined) {
return createMaterial(spec.material, shading)
}
const roofMaterials = parentRoof
? getRoofMaterialArray(parentRoof, shading, textures, colorPreset, sceneTheme)
: null
return (
roofMaterials?.[3] ??
createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
)
}
return node.material
? createMaterial(node.material, shading)
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
if (node.material) {
return createMaterial(node.material, shading)
}
if (node.materialPreset) {
return createMaterialFromPresetRef(node.materialPreset, shading) ?? createDefaultTopMaterial()
}
return createDefaultTopMaterial()
}, [
textures,
colorPreset,
sceneTheme,
shading,
node.material,
node.materialPreset,
segment,
parentRoof,
])
// Map vent-local geometry into the host segment's local frame (where the trim
// cut prisms live). Recompose the same pose the inner mesh group is mounted
// with — ridge snap for X/Z, slope-locked base Y + offset, yaw — so the clip
// matches the rendered placement. Computed before the early return so the
// hook order stays stable.
const localToSegment = useMemo(() => {
if (!segment) return new THREE.Matrix4()
const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment)
const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0))
const ridgeY = baseY + RIDGE_LIFT + yOffset
return new THREE.Matrix4().compose(
new THREE.Vector3(ridgeX, ridgeY, ridgeZ),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotationY),
new THREE.Vector3(1, 1, 1),
)
}, [segment, ridgeX, ridgeZ, rotationY, nodePosition[1]])
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
if (!segment) return null
@@ -126,33 +241,29 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
const segPos = segment.position ?? [0, 0, 0]
const segRotY = segment.rotation ?? 0
// Lock the BASE position to the ridge so the vent always starts on the
// slope top; treat `position[1]` and `position[2]` as user-tunable OFFSETS
// off that base (Y above ridge lift, Z away from ridge centerline). So
// Lock the BASE position to the rendered roof skin so the vent always starts
// on the roof structure; treat `position[1]` and `position[2]` as user-tunable OFFSETS
// off that base (Y above the surface, Z away from ridge centerline). So
// after placement the inspector's Y / Z sliders nudge the vent off the
// locked ridge without losing the slope-tracking base. X is the position
// along the ridge — the snap re-clamps it to the segment's ridge span.
const snap = resolveRidgeSnap(segment, node.position[0] ?? 0, 0)
const ridgeX = snap ? snap.localX : (node.position[0] ?? 0)
const baseZ = snap ? snap.localZ : 0
const baseY = getSurfaceY(ridgeX, baseZ, segment) + RIDGE_LIFT
const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment)
// Clamp legacy stored Y (absolute peak height from earlier versions) so the
// vent doesn't fly off when the field was an absolute Y instead of offset.
const yOffset = Math.max(-2, Math.min(2, node.position[1] ?? 0))
const ridgeY = baseY + yOffset
const ridgeZ = baseZ + (node.position[2] ?? 0)
const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0))
const ridgeY = baseY + RIDGE_LIFT + yOffset
return (
<group position={segPos} rotation-y={segRotY}>
<group
position={[ridgeX, ridgeY, ridgeZ]}
ref={ref}
rotation-y={node.rotation ?? 0}
rotation-y={rotationY}
visible={node.visible}
>
<mesh
castShadow
geometry={geometry}
geometry={clippedGeometry ?? geometry}
material={material}
name="ridge-vent-surface"
receiveShadow
+19 -12
View File
@@ -16,6 +16,7 @@ import * as THREE from 'three'
import { resolveRidgeSnap } from '../shared/ridge-snap'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getRoofTopSurfaceY } from '../shared/roof-surface'
import {
clearRoofSurfacePlacementGuides,
publishRoofSurfacePlacementGuides,
@@ -27,11 +28,9 @@ import RidgeVentPreview from './preview'
const worldPoint = new THREE.Vector3()
/**
* Ridge vent placement tool. The cursor preview snaps to the ridge
* (Z=0 in segment-local space) of whichever segment is under the
* cursor, since the ridge vent's whole purpose is to sit on the peak.
* Click anywhere on a segment commits the vent at the ridge directly
* above that hit (X stays where the cursor was, Z snaps to 0).
* Ridge vent placement tool. The cursor preview snaps to the nearest
* ridge/break line of whichever segment is under the cursor, since the
* cap needs to straddle a real roof crease.
*/
const RidgeVentTool = () => {
const activeBuildingId = useViewer((s) => s.selection.buildingId)
@@ -72,9 +71,9 @@ const RidgeVentTool = () => {
)
if (!hit) return
// Project the cursor onto the segment's ridge line (clamped to the
// segment's ridge span). The preview then moves ALONG the ridge as the
// cursor moves — never off it. Flat segments have no ridge: hide.
// Project the cursor onto the nearest segment ridge/break line.
// The preview then moves along that line as the cursor moves — never
// off it. Flat segments have no ridge: hide.
const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
if (!snap) {
setPreviewPos(null)
@@ -84,7 +83,11 @@ const RidgeVentTool = () => {
const segObj = sceneRegistry.nodes.get(hit.segment.id)
let ridgeWorld: [number, number, number]
if (segObj) {
const ridgeLocal = new THREE.Vector3(snap.localX, hit.localY, snap.localZ)
const ridgeLocal = new THREE.Vector3(
snap.localX,
getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment),
snap.localZ,
)
segObj.updateWorldMatrix(true, false)
ridgeLocal.applyMatrix4(segObj.matrixWorld)
ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z]
@@ -100,12 +103,16 @@ const RidgeVentTool = () => {
lastSnapRef.current = [sx, sz]
}
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation)
setPreviewPos(worldToBuildingLocal(ridgeWorld[0], ridgeWorld[1], ridgeWorld[2]))
publishRoofSurfacePlacementGuides({
roof: event.node as RoofNode,
segment: hit.segment,
center: [snap.localX, hit.localY, snap.localZ],
center: [
snap.localX,
getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment),
snap.localZ,
],
footprint: roofSurfaceFootprintFromNode(previewNode),
mode: 'linear-edge',
})
@@ -129,7 +136,7 @@ const RidgeVentTool = () => {
name: 'Ridge Vent',
roofSegmentId: hit.segment.id,
position: [snap.localX, 0, snap.localZ],
rotation: 0,
rotation: snap.rotation,
})
state.createNode(vent, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)