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
+13
View File
@@ -194,6 +194,19 @@ export {
resolveElevatorServiceLevelIds,
resolveElevatorServiceLevels,
} from './systems/elevator/elevator-service'
export {
getFenceCenterlineFrameAt,
getFenceCenterlineLength,
sampleFenceCenterline,
} from './systems/fence/fence-centerline'
export {
getFenceControlHandle,
getFenceSplineFrameAt,
getFenceSplineLength,
getTwoPointFenceCurveTangents,
isSplineFence,
sampleFenceSpline,
} from './systems/fence/fence-spline'
export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint'
export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
+16
View File
@@ -55,6 +55,17 @@ export type EditorApi = {
* fences). No-ops for kinds without endpoints.
*/
engageEndpointMove: (node: AnyNode, endpoint: 'start' | 'end') => void
/**
* Engage drag of a spline control point (`path[index]`). Used by spline
* fences to reshape their centerline. No-ops for kinds without a path.
*/
engageControlPointMove: (node: AnyNode, index: number) => void
/**
* Engage drag of a spline tangent handle (`path[index]`, which end). Used by
* spline fences to bend the curve through one control point. No-ops for kinds
* without tangents.
*/
engageTangentMove: (node: AnyNode, index: number, side: 'in' | 'out') => void
}
export type HandlePortal = 'self' | 'parent' | 'grandparent'
@@ -323,6 +334,11 @@ export type TapActionHandle<N = any> = {
* drag, so the move tool's own preview / ticker feedback shows up.
*/
shape?: 'arrow' | 'corner-picker' | 'move-cross'
/**
* `shape: 'corner-picker'` only — render the disc and its outer ring as a
* circle instead of the default hexagon.
*/
round?: boolean
/**
* Required when `shape: 'corner-picker'` — controls the dashed leader's
* vertical extent. Pure callback so the descriptor doesn't need to
+29 -1
View File
@@ -98,25 +98,53 @@ export { PipeFittingNode } from './nodes/pipe-fitting'
export { PipeSegmentNode } from './nodes/pipe-segment'
export { PipeTrapNode } from './nodes/pipe-trap'
// Nodes
export { RidgeVentNode } from './nodes/ridge-vent'
export {
createDefaultRidgeVentsForSegment,
getRidgeVentLinesForSegment,
hasAutoRidgeVentMetadata,
isAutoRidgeVentEnabled,
isDefaultRidgeVentNode,
type RidgeVentLine,
RidgeVentNode,
} from './nodes/ridge-vent'
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export type {
DutchRoofMetrics,
RoofSegmentSurfaceMaterialRole,
RoofSegmentSurfaceMaterialSpec,
RoofSegmentVisibleTopBounds,
SegmentSlopeFrame,
} from './nodes/roof-segment'
export {
getActiveRoofHeight,
getDutchRoofMetrics,
getEffectiveSegmentSurfaceMaterial,
getPitchFromActiveRoofHeight,
getRoofSegmentSurfaceY,
getRoofSegmentVisibleTopBounds,
getSegmentSlopeFrame,
hasSegmentMaterialOverride,
MIN_ROOF_SEGMENT_TRIM_SPAN,
normalizeRoofSegmentTrim,
ROOF_SHAPE_DEFAULTS,
RoofSegmentNode,
RoofSegmentTrim,
RoofType,
} from './nodes/roof-segment'
export type {
DutchRoofShapeMetrics,
RoofShapeFaceVertex,
RoofShapeInsets,
RoofShapeRatios,
} from './nodes/roof-segment-shape'
export {
getDutchEndSlopeFaces,
getDutchRoofShapeMetrics,
getRoofModuleFaces,
getRoofShapeInsets,
getRoofShapeRatios,
} from './nodes/roof-segment-shape'
export type { RoofSegmentWallFace, RoofWallFaceId } from './nodes/roof-segment-walls'
export {
clampRectToRoofWallFace,
+16 -1
View File
@@ -17,6 +17,19 @@ export const FenceNode = BaseNode.extend({
slots: z.record(z.string(), z.string()).optional(),
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
// Optional spline control points in level coordinate meters. When present
// (>= 2 points) the fence centerline is a smooth Catmull-Rom curve through
// these points and start/end/curveOffset no longer define the centerline.
// start/end are kept in sync with the first/last path point so consumers
// that read endpoints (handles, bbox, miter references) stay valid. Absent =
// the straight or single-arc fence defined by start/end (+ curveOffset).
path: z.array(z.tuple([z.number(), z.number()])).optional(),
// Optional per-control-point tangent handles, parallel to `path` (same
// length when present). Each entry is the OUT-handle offset vector [dx, dy]
// from its path point, in level meters; the IN handle is its mirror so the
// curve stays smooth through the point. `null` = use the automatic
// Catmull-Rom tangent for that point. Only meaningful for spline fences.
tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(),
height: z.number().default(1.8),
thickness: z.number().default(0.08),
curveOffset: z.number().optional(),
@@ -38,8 +51,10 @@ export const FenceNode = BaseNode.extend({
dedent`
Fence node - used to represent a fence segment in the building/site level coordinate system
- start/end: fence endpoints in level coordinate system
- path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them
- tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent
- height/thickness: overall fence dimensions in meters
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set)
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
- showInfill: whether to draw intermediate posts/slats between end posts
@@ -0,0 +1,275 @@
import { describe, expect, test } from 'bun:test'
import {
createDefaultRidgeVentsForSegment,
getRidgeVentLinesForSegment,
isAutoRidgeVentEnabled,
isDefaultRidgeVentNode,
RidgeVentNode,
} from './ridge-vent'
import {
getDutchRoofMetrics,
getRoofSegmentVisibleTopBounds,
ROOF_SHAPE_DEFAULTS,
RoofSegmentNode,
} from './roof-segment'
describe('createDefaultRidgeVentsForSegment', () => {
test('creates one shingled default ridge vent for gable roofs', () => {
const segment = RoofSegmentNode.parse({
roofType: 'gable',
width: 8,
depth: 6,
})
const vents = createDefaultRidgeVentsForSegment(segment)
expect(vents).toHaveLength(1)
expect(vents[0]?.name).toBe('Ridge Vent')
expect(vents[0]?.style).toBe('shingled')
expect(vents[0]?.roofSegmentId).toBe(segment.id)
expect(isDefaultRidgeVentNode(vents[0], segment.id)).toBe(true)
})
test('keeps generated gable ridge vents anchored to the untrimmed ridge', () => {
const segment = RoofSegmentNode.parse({
roofType: 'gable',
width: 8,
depth: 6,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
trim: { left: 1, right: 2, front: 0, back: 0 },
})
const vents = createDefaultRidgeVentsForSegment(segment)
expect(vents).toHaveLength(1)
expect(vents[0]?.length).toBeCloseTo(8)
expect(vents[0]?.position[0]).toBeCloseTo(0)
})
test('creates top ridge plus four hip vents for rectangular hip roofs', () => {
const segment = RoofSegmentNode.parse({
roofType: 'hip',
width: 8,
depth: 6,
})
const vents = createDefaultRidgeVentsForSegment(segment)
expect(vents).toHaveLength(5)
expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1)
expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4)
for (const vent of vents) {
expect(vent.style).toBe('shingled')
expect(vent.length).toBeGreaterThan(0.4)
expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true)
}
})
test('omits the collapsed top ridge on square hip roofs', () => {
const segment = RoofSegmentNode.parse({
roofType: 'hip',
width: 6,
depth: 6,
})
const vents = createDefaultRidgeVentsForSegment(segment)
expect(vents).toHaveLength(4)
expect(vents.every((vent) => vent.name === 'Hip Ridge Vent')).toBe(true)
})
test('creates a top ridge plus four hip vents for width-axis Dutch roofs', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const vents = createDefaultRidgeVentsForSegment(segment)
expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1)
expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4)
// Width-axis Dutch ridge runs along X (constant Z = 0).
const metrics = getDutchRoofMetrics(segment)
const ridge = vents.find((vent) => vent.name === 'Ridge Vent')
const frontRightHip = vents.find(
(vent) =>
vent.name === 'Hip Ridge Vent' &&
(vent.position[0] ?? 0) > 0 &&
(vent.position[2] ?? 0) > 0,
)
const expectedRakeReach = Math.min(
segment.dutchGabletRake,
Math.max(0, segment.width / 2 - metrics.waistHalfX) * 0.98,
)
expect(ridge?.position[2]).toBeCloseTo(0)
expect(ridge?.length).toBeCloseTo((metrics.waistHalfX + expectedRakeReach) * 2, 2)
expect(frontRightHip?.position[0]).toBeCloseTo((4 + 2.93) / 2, 2)
expect(frontRightHip?.position[2]).toBeCloseTo((3 + 1.5) / 2, 2)
for (const vent of vents) {
expect(vent.style).toBe('shingled')
expect(vent.length).toBeGreaterThan(0.4)
expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true)
}
})
test('treats legacy segments with generated ridge vents as auto-enabled', () => {
const segment = RoofSegmentNode.parse({
roofType: 'gable',
width: 8,
depth: 6,
})
const vents = createDefaultRidgeVentsForSegment(segment)
const nodes = Object.fromEntries(vents.map((vent) => [vent.id, vent]))
expect(
isAutoRidgeVentEnabled(
{
id: segment.id,
children: vents.map((vent) => vent.id),
metadata: {},
},
nodes,
),
).toBe(true)
})
test('treats legacy preset-white ridge vents as generated defaults', () => {
const segment = RoofSegmentNode.parse({
id: 'rseg_test' as never,
roofType: 'gable',
width: 8,
depth: 6,
})
const legacyVent = RidgeVentNode.parse({
id: 'rvent_legacy' as never,
roofSegmentId: segment.id,
name: 'Ridge Vent',
style: 'shingled',
materialPreset: 'preset-white',
position: [0, 0, 0],
length: 8,
})
expect(isDefaultRidgeVentNode(legacyVent, segment.id)).toBe(true)
})
test('creates a Z-oriented ridge plus four hip lines for depth-axis Dutch roofs', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 6,
depth: 8,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const lines = getRidgeVentLinesForSegment(segment)
const metrics = getDutchRoofMetrics(segment)
const expectedRakeReach = Math.min(
segment.dutchGabletRake,
Math.max(0, segment.depth / 2 - metrics.waistHalfZ) * 0.98,
)
const ridges = lines.filter((line) => line.name === 'Ridge Vent')
const hips = lines.filter((line) => line.name === 'Hip Ridge Vent')
expect(ridges).toHaveLength(1)
expect(hips).toHaveLength(4)
// Depth-axis Dutch ridge runs along Z (constant X = 0).
expect(ridges[0]?.start[0]).toBeCloseTo(0)
expect(ridges[0]?.end[0]).toBeCloseTo(0)
expect(Math.abs(ridges[0]?.start[1] ?? 0)).toBeCloseTo(
metrics.waistHalfZ + expectedRakeReach,
2,
)
})
test('keeps Dutch hip lines on the rendered arris when the roof has overhang', () => {
// Overhang + shingle thickness expand the rendered roof; the eave corners
// and the waist must share that expanded frame, otherwise the hip lines
// tilt off the arris and the vents sink into the slope.
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
overhang: 0.5,
wallThickness: 0.2,
shingleThickness: 0.05,
})
const lines = getRidgeVentLinesForSegment(segment)
const hips = lines.filter((line) => line.name === 'Hip Ridge Vent')
const ridge = lines.find((line) => line.name === 'Ridge Vent')
expect(hips).toHaveLength(4)
expect(ridge).toBeDefined()
const bounds = getRoofSegmentVisibleTopBounds(segment)
const { axis, inset } = getDutchRoofMetrics(segment)
const waistLengthRatio = segment.dutchWaistLengthRatio
// width 8 >= depth 6 -> axis 'x': the ridge runs along X (waist scaled by
// waistLengthRatio), and Z is the clean hipped axis (inset exactly).
expect(axis).toBe('x')
const halfWExpanded = bounds.maxX
const halfDExpanded = bounds.maxZ
const expectedWaistX = (halfWExpanded - inset) * waistLengthRatio
const expectedWaistZ = halfDExpanded - inset
const expectedRakeReach = Math.min(
segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake,
Math.max(0, halfWExpanded - expectedWaistX) * 0.98,
)
for (const hip of hips) {
const [ex, ez] = hip.start
const [wx, wz] = hip.end
// Eave end on an expanded-bounds corner; upper end at the rendered rake
// termination where the lower slope starts, derived from the SAME
// expanded frame (not the base-dim inner waist).
expect(Math.abs(ex)).toBeCloseTo(halfWExpanded)
expect(Math.abs(ez)).toBeCloseTo(halfDExpanded)
expect(Math.abs(wx)).toBeCloseTo(expectedWaistX + expectedRakeReach)
expect(Math.abs(wz)).toBeCloseTo(expectedWaistZ)
}
})
test('creates top ridge plus four upper hip vents plus four lower-slope vents for mansard roofs', () => {
const segment = RoofSegmentNode.parse({
roofType: 'mansard',
width: 8,
depth: 6,
})
const vents = createDefaultRidgeVentsForSegment(segment)
expect(vents).toHaveLength(9)
expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1)
expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4)
expect(vents.filter((vent) => vent.name === 'Slope Ridge Vent')).toHaveLength(4)
expect(vents.find((vent) => vent.name === 'Ridge Vent')?.length).toBeLessThan(segment.width)
const slopeVents = vents.filter((vent) => vent.name === 'Slope Ridge Vent')
const slopeRotations = slopeVents.map((vent) => Math.abs(vent.rotation))
expect(slopeRotations.every((rotation) => rotation > 0.1)).toBe(true)
expect(slopeRotations.every((rotation) => Math.abs(rotation - Math.PI / 2) > 0.1)).toBe(true)
expect(
slopeVents.every(
(vent) =>
Math.abs(vent.position[0]) > segment.width / 2 - 0.8 &&
Math.abs(vent.position[2]) > segment.depth / 2 - 0.8,
),
).toBe(true)
expect(
new Set(
slopeVents.map((vent) => `${Math.sign(vent.position[0])},${Math.sign(vent.position[2])}`),
).size,
).toBe(4)
for (const vent of vents) {
expect(vent.style).toBe('shingled')
expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true)
}
})
})
+271 -3
View File
@@ -2,15 +2,50 @@ import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import {
getDutchRoofMetrics,
getRoofSegmentVisibleTopBounds,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
type RoofSegmentTrim,
} from './roof-segment'
const MIN_DEFAULT_RIDGE_VENT_LENGTH_M = 0.4
const DEFAULT_RIDGE_VENT_GENERATOR = 'default-ridge-vent'
const AUTO_RIDGE_VENT_METADATA_KEY = 'autoRidgeVent'
const LEGACY_DEFAULT_RIDGE_VENT_PRESET = 'preset-white'
const UNTRIMMED_RIDGE_VENT_BOUNDS_TRIM: RoofSegmentTrim = {
left: 0,
right: 0,
front: 0,
back: 0,
frontLeft: 0,
frontRight: 0,
backLeft: 0,
backRight: 0,
frontLeftX: 0,
frontLeftZ: 0,
frontRightX: 0,
frontRightZ: 0,
backLeftX: 0,
backLeftZ: 0,
backRightX: 0,
backRightZ: 0,
}
export type RidgeVentLine = {
name: string
start: [number, number]
end: [number, number]
}
export const RidgeVentNode = BaseNode.extend({
id: objectId('rvent'),
type: nodeType('ridge-vent'),
material: MaterialSchema.optional(),
// See note on box-vent: default to white so the paint inspector
// reflects the current visual state instead of "no material".
materialPreset: z.string().default('preset-white'),
// Unpainted ridge vents inherit the roof top material in the renderer.
materialPreset: z.string().optional(),
roofSegmentId: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
@@ -37,3 +72,236 @@ export const RidgeVentNode = BaseNode.extend({
)
export type RidgeVentNode = z.infer<typeof RidgeVentNode>
export function getRidgeVentLinesForSegment(segment: RoofSegmentNode): RidgeVentLine[] {
const bounds = getRoofSegmentVisibleTopBounds({
...segment,
trim: UNTRIMMED_RIDGE_VENT_BOUNDS_TRIM,
})
const { width, depth, minX, maxX, minZ, maxZ } = bounds
if (segment.roofType === 'flat' || segment.roofType === 'shed') return []
const halfW = width / 2
const halfD = depth / 2
const ridgeZVisible = minZ <= 0 && maxZ >= 0
const ridgeXVisible = minX <= 0 && maxX >= 0
if (segment.roofType === 'mansard') {
const inset = Math.min(
Math.min(width, depth) *
(segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio),
Math.max(0, Math.min(width, depth) / 2 - 0.01),
)
const shoulderMinX = minX + inset
const shoulderMaxX = maxX - inset
const shoulderMinZ = minZ + inset
const shoulderMaxZ = maxZ - inset
const topW = Math.max(0, shoulderMaxX - shoulderMinX)
const topD = Math.max(0, shoulderMaxZ - shoulderMinZ)
const lowerSlopeLines: RidgeVentLine[] = [
{
name: 'Slope Ridge Vent',
start: [shoulderMinX, shoulderMaxZ],
end: [minX, maxZ],
},
{
name: 'Slope Ridge Vent',
start: [shoulderMaxX, shoulderMaxZ],
end: [maxX, maxZ],
},
{
name: 'Slope Ridge Vent',
start: [shoulderMaxX, shoulderMinZ],
end: [maxX, minZ],
},
{
name: 'Slope Ridge Vent',
start: [shoulderMinX, shoulderMinZ],
end: [minX, minZ],
},
]
if (topW >= topD) {
const leftRidge: [number, number] = [shoulderMinX + topD / 2, 0]
const rightRidge: [number, number] = [shoulderMaxX - topD / 2, 0]
return [
...(ridgeZVisible ? [{ name: 'Ridge Vent', start: leftRidge, end: rightRidge }] : []),
{ name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMaxZ], end: leftRidge },
{ name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMinZ], end: leftRidge },
{ name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMaxZ], end: rightRidge },
{ name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMinZ], end: rightRidge },
...lowerSlopeLines,
]
}
const frontRidge: [number, number] = [0, shoulderMaxZ - topW / 2]
const backRidge: [number, number] = [0, shoulderMinZ + topW / 2]
return [
...(ridgeXVisible ? [{ name: 'Ridge Vent', start: frontRidge, end: backRidge }] : []),
{ name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMaxZ], end: frontRidge },
{ name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMaxZ], end: frontRidge },
{ name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMinZ], end: backRidge },
{ name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMinZ], end: backRidge },
...lowerSlopeLines,
]
}
if (segment.roofType === 'dutch') {
const { axis, inset } = getDutchRoofMetrics(segment)
// The rendered hip arris and waist come from the EXPANDED (overhang +
// shingle) rectangle — the same dims the eave corners below use — while
// the inset is base-derived (matches getDutchRoofMetrics / the brush
// builder). Deriving the waist from `getDutchRoofMetrics`' own base-dim
// half-spans would tilt the hip lines off the arris on any roof with
// overhang, sinking the vents into the slope. Mirror the mansard branch:
// one coordinate system (expanded bounds) for both eave and waist.
const waistLengthRatio =
segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio
const waistHalfX =
axis === 'x' ? Math.max(0, (halfW - inset) * waistLengthRatio) : Math.max(0, halfW - inset)
const waistHalfZ =
axis === 'x' ? Math.max(0, halfD - inset) : Math.max(0, (halfD - inset) * waistLengthRatio)
if (!(waistHalfX > 0.001 && waistHalfZ > 0.001)) return []
// Dutch lower hip vents should terminate where the lower slope actually
// meets the gablet rake, not at the inner waist line of the upper gable
// triangle. Mirror the rendered roof shell's "outer waist" cap with the
// same expanded-bounds frame we use for the eave corners above.
const rake = segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake
const rakeReach =
axis === 'x'
? Math.min(Math.max(0, rake), Math.max(0, halfW - waistHalfX) * 0.98)
: Math.min(Math.max(0, rake), Math.max(0, halfD - waistHalfZ) * 0.98)
const rakeEndHalfX = axis === 'x' ? waistHalfX + rakeReach : waistHalfX
const rakeEndHalfZ = axis === 'x' ? waistHalfZ : waistHalfZ + rakeReach
const mainRidgeVisible = axis === 'x' ? ridgeZVisible : ridgeXVisible
const ridgeStart: [number, number] = axis === 'x' ? [-rakeEndHalfX, 0] : [0, rakeEndHalfZ]
const ridgeEnd: [number, number] = axis === 'x' ? [rakeEndHalfX, 0] : [0, -rakeEndHalfZ]
return [
...(mainRidgeVisible ? [{ name: 'Ridge Vent', start: ridgeStart, end: ridgeEnd }] : []),
{ name: 'Hip Ridge Vent', start: [minX, maxZ], end: [-rakeEndHalfX, rakeEndHalfZ] },
{ name: 'Hip Ridge Vent', start: [maxX, maxZ], end: [rakeEndHalfX, rakeEndHalfZ] },
{ name: 'Hip Ridge Vent', start: [maxX, minZ], end: [rakeEndHalfX, -rakeEndHalfZ] },
{ name: 'Hip Ridge Vent', start: [minX, minZ], end: [-rakeEndHalfX, -rakeEndHalfZ] },
]
}
if (segment.roofType !== 'hip') {
if (!ridgeZVisible) return []
return [
{
name: 'Ridge Vent',
start: [minX, 0],
end: [maxX, 0],
},
]
}
if (width >= depth) {
const leftRidge: [number, number] = [minX + halfD, 0]
const rightRidge: [number, number] = [maxX - halfD, 0]
return [
...(ridgeZVisible ? [{ name: 'Ridge Vent', start: leftRidge, end: rightRidge }] : []),
{ name: 'Hip Ridge Vent', start: [minX, maxZ], end: leftRidge },
{ name: 'Hip Ridge Vent', start: [minX, minZ], end: leftRidge },
{ name: 'Hip Ridge Vent', start: [maxX, maxZ], end: rightRidge },
{ name: 'Hip Ridge Vent', start: [maxX, minZ], end: rightRidge },
]
}
const frontRidge: [number, number] = [0, maxZ - halfW]
const backRidge: [number, number] = [0, minZ + halfW]
return [
...(ridgeXVisible ? [{ name: 'Ridge Vent', start: frontRidge, end: backRidge }] : []),
{ name: 'Hip Ridge Vent', start: [minX, maxZ], end: frontRidge },
{ name: 'Hip Ridge Vent', start: [maxX, maxZ], end: frontRidge },
{ name: 'Hip Ridge Vent', start: [minX, minZ], end: backRidge },
{ name: 'Hip Ridge Vent', start: [maxX, minZ], end: backRidge },
]
}
function getLineYaw(start: [number, number], end: [number, number]): number {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
return Math.atan2(-dz, dx)
}
export function createDefaultRidgeVentsForSegment(segment: RoofSegmentNode): RidgeVentNode[] {
return getRidgeVentLinesForSegment(segment)
.map((line) => {
const length = Math.hypot(line.end[0] - line.start[0], line.end[1] - line.start[1])
if (length < MIN_DEFAULT_RIDGE_VENT_LENGTH_M) return null
return RidgeVentNode.parse({
name: line.name,
roofSegmentId: segment.id,
position: [(line.start[0] + line.end[0]) / 2, 0, (line.start[1] + line.end[1]) / 2],
rotation: getLineYaw(line.start, line.end),
length,
style: 'shingled',
metadata: { generatedBy: DEFAULT_RIDGE_VENT_GENERATOR },
})
})
.filter((vent): vent is RidgeVentNode => vent !== null)
}
export function isDefaultRidgeVentNode(
node: unknown,
roofSegmentId?: RoofSegmentNode['id'],
): node is RidgeVentNode {
const parsed = RidgeVentNode.safeParse(node)
if (!parsed.success) return false
const vent = parsed.data
if (roofSegmentId && vent.roofSegmentId !== roofSegmentId) return false
const metadata = vent.metadata
if (
typeof metadata === 'object' &&
metadata !== null &&
(metadata as Record<string, unknown>).generatedBy === DEFAULT_RIDGE_VENT_GENERATOR
) {
return true
}
const hasDefaultName =
vent.name === 'Ridge Vent' ||
vent.name === 'Hip Ridge Vent' ||
vent.name === 'Shoulder Ridge Vent' ||
vent.name === 'Slope Ridge Vent'
return (
hasDefaultName &&
vent.style === 'shingled' &&
vent.material === undefined &&
vent.materialPreset === LEGACY_DEFAULT_RIDGE_VENT_PRESET
)
}
function metadataRecord(metadata: unknown): Record<string, unknown> {
if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) {
return metadata as Record<string, unknown>
}
return {}
}
export function hasAutoRidgeVentMetadata(
segment: Pick<RoofSegmentNode, 'metadata'>,
): segment is Pick<RoofSegmentNode, 'metadata'> & {
metadata: Record<string, unknown> & { autoRidgeVent: boolean }
} {
return typeof metadataRecord(segment.metadata)[AUTO_RIDGE_VENT_METADATA_KEY] === 'boolean'
}
export function isAutoRidgeVentEnabled(
segment: Pick<RoofSegmentNode, 'id' | 'children' | 'metadata'>,
nodes?: Record<string, unknown>,
): boolean {
const metadataValue = metadataRecord(segment.metadata)[AUTO_RIDGE_VENT_METADATA_KEY]
if (typeof metadataValue === 'boolean') {
return metadataValue
}
if (!nodes) return false
return (segment.children ?? []).some((childId) =>
isDefaultRidgeVentNode(nodes[childId], segment.id),
)
}
@@ -0,0 +1,229 @@
import { describe, expect, test } from 'bun:test'
import {
getDutchEndSlopeFaces,
getDutchRoofShapeMetrics,
getRoofModuleFaces,
getRoofShapeRatios,
} from './roof-segment-shape'
describe('roof segment shape', () => {
test('dutch shell is built as one complete non-duplicated face set', () => {
const wh = 3
const rh = 2
const faces = getRoofModuleFaces({
type: 'dutch',
w: 8,
d: 6,
wh,
rh,
baseY: 0,
insets: { dutchI: 1.5 },
baseW: 8,
baseD: 6,
tanTheta: 1,
shapeRatios: getRoofShapeRatios({
dutchHipWidthRatio: 0.25,
dutchHipHeightRatio: 0.5,
dutchWaistLengthRatio: 1,
dutchGabletRake: 0.25,
}),
})
const peakY = wh + rh
const peakFaces = faces.filter((face) =>
face.some((vertex) => Math.abs(vertex.y - peakY) < 1e-6),
)
const signatures = new Set(
faces.map((face) =>
face
.map((vertex) => `${vertex.x.toFixed(3)},${vertex.y.toFixed(3)},${vertex.z.toFixed(3)}`)
.sort()
.join('|'),
),
)
expect(faces).toHaveLength(13)
expect(peakFaces).toHaveLength(4)
expect(signatures.size).toBe(faces.length)
const lowerRightHip = faces.find((face) =>
face.some(
(vertex) =>
Math.abs(vertex.x - 2.75) < 1e-6 &&
Math.abs(vertex.y - 4) < 1e-6 &&
Math.abs(vertex.z - 1.5) < 1e-6,
),
)
const gableTriangle = faces.find(
(face) =>
face.length === 3 &&
face.some((vertex) => vertex.x === 2.5 && vertex.y === 5) &&
face.some((vertex) => vertex.x === 2.5 && vertex.z === 1.5),
)
expect(lowerRightHip).toBeDefined()
expect(gableTriangle?.some((vertex) => vertex.x === 2.75)).toBe(false)
})
test('dutch depth-axis shell rotates the upper gable correctly', () => {
const faces = getRoofModuleFaces({
type: 'dutch',
w: 6,
d: 8,
wh: 3,
rh: 2,
baseY: 0,
insets: { dutchI: 1.5 },
baseW: 6,
baseD: 8,
tanTheta: 1,
shapeRatios: getRoofShapeRatios({
dutchHipWidthRatio: 0.25,
dutchHipHeightRatio: 0.5,
dutchWaistLengthRatio: 1,
dutchGabletRake: 0.25,
}),
})
const peakFaces = faces.filter((face) => face.some((vertex) => vertex.y === 5))
const ridgeOnlyOnZAxis = peakFaces
.flat()
.filter((vertex) => vertex.y === 5)
.every((vertex) => vertex.x === 0)
expect(peakFaces).toHaveLength(4)
expect(ridgeOnlyOnZAxis).toBe(true)
})
test('dutch end hip slope extends inward until it meets the gable triangle', () => {
const ratios = getRoofShapeRatios({
dutchHipWidthRatio: 0.25,
dutchHipHeightRatio: 0.5,
dutchWaistLengthRatio: 1,
dutchGabletRake: 0.75,
})
const metrics = getDutchRoofShapeMetrics({
w: 8,
d: 6,
wh: 3,
rh: 2,
dutchI: 1.5,
baseW: 8,
baseD: 6,
shapeRatios: ratios,
})
const faces = getRoofModuleFaces({
type: 'dutch',
w: 8,
d: 6,
wh: 3,
rh: 2,
baseY: 0,
insets: { dutchI: 1.5 },
baseW: 8,
baseD: 6,
tanTheta: 1,
shapeRatios: ratios,
dutchTopRakeThickness: 0.21,
})
expect(metrics?.innerWaistHalfX).toBe(2.5)
expect(metrics?.outerWaistHalfX).toBe(3.25)
// The end slope now clips against the rake's lower inner edge and is
// reprojected back onto the end-slope plane, so the shorter top edge stays
// planar instead of twisting.
const hipTopFace = faces.find(
(face) =>
face.some(
(vertex) =>
Math.abs(vertex.x - 4) < 1e-6 &&
Math.abs(vertex.y - 3) < 1e-6 &&
Math.abs(vertex.z - 3) < 1e-6,
) &&
face.some(
(vertex) =>
Math.abs(vertex.x - 3.0325) < 1e-6 &&
Math.abs(vertex.y - 4.29) < 1e-6 &&
Math.abs(vertex.z - 0.75) < 1e-6,
) &&
face.some(
(vertex) =>
Math.abs(vertex.x - 3.0325) < 1e-6 &&
Math.abs(vertex.y - 4.29) < 1e-6 &&
Math.abs(vertex.z + 0.75) < 1e-6,
),
)
const gableTriangle = faces.find(
(face) =>
face.length === 3 &&
face.some((vertex) => vertex.x === 2.5 && vertex.y === 5) &&
face.some((vertex) => vertex.x === 2.5 && vertex.z === 1.5),
)
expect(hipTopFace).toBeDefined()
expect(gableTriangle?.some((vertex) => vertex.x === 3.25)).toBe(false)
})
test('excludeDutchEndSlopes pulls the end slopes out into getDutchEndSlopeFaces', () => {
const ratios = getRoofShapeRatios({
dutchHipWidthRatio: 0.25,
dutchHipHeightRatio: 0.5,
dutchWaistLengthRatio: 1,
dutchGabletRake: 0.75,
})
const args = {
type: 'dutch' as const,
w: 8,
d: 6,
wh: 3,
rh: 2,
baseY: 0,
insets: { dutchI: 1.5 },
baseW: 8,
baseD: 6,
tanTheta: 1,
shapeRatios: ratios,
dutchTopRakeThickness: 0.21,
}
const full = getRoofModuleFaces(args)
const shell = getRoofModuleFaces({ ...args, excludeDutchEndSlopes: true })
const endSlopes = getDutchEndSlopeFaces({
w: 8,
d: 6,
wh: 3,
rh: 2,
insets: { dutchI: 1.5 },
baseW: 8,
baseD: 6,
shapeRatios: ratios,
dutchTopRakeThickness: 0.21,
})
// The two end slopes leave the shell and reappear in the standalone set.
expect(shell).toHaveLength(full.length - 2)
expect(endSlopes).toHaveLength(2)
// The standalone end slopes are 6-point polygons whose shorter top edge is
// clipped to the rake's lower inner edge and then kept on the end-slope
// plane.
const endIsEndSlope = endSlopes.every(
(face) =>
face.length === 6 &&
face.some((vertex) => Math.abs(Math.abs(vertex.x) - 3.25) < 1e-6) &&
face.some((vertex) => Math.abs(Math.abs(vertex.x) - 3.0325) < 1e-6) &&
face.some((vertex) => Math.abs(Math.abs(vertex.x) - 4) < 1e-6),
)
expect(endIsEndSlope).toBe(true)
// The removed faces are exactly the end slopes — the shell keeps every
// other face the full module produced.
const signature = (face: { x: number; y: number; z: number }[]) =>
face
.map((vertex) => `${vertex.x.toFixed(3)},${vertex.y.toFixed(3)},${vertex.z.toFixed(3)}`)
.join('|')
const shellSigs = new Set(shell.map(signature))
const removed = full.filter((face) => !shellSigs.has(signature(face)))
expect(removed).toHaveLength(2)
})
})
@@ -0,0 +1,441 @@
import { ROOF_SHAPE_DEFAULTS, type RoofType } from './roof-segment'
export type RoofShapeFaceVertex = {
x: number
y: number
z: number
}
export type RoofShapeInsets = {
iF?: number
iB?: number
iL?: number
iR?: number
dutchI?: number
}
export type RoofShapeRatios = {
gambrelLowerWidthRatio: number
mansardSteepWidthRatio: number
dutchHipWidthRatio: number
dutchHipHeightRatio: number
dutchWaistLengthRatio: number
dutchGabletRake: number
}
export function getRoofShapeRatios(input: {
gambrelLowerWidthRatio?: number
mansardSteepWidthRatio?: number
dutchHipWidthRatio?: number
dutchHipHeightRatio?: number
dutchWaistLengthRatio?: number
dutchGabletRake?: number
}): RoofShapeRatios {
return {
gambrelLowerWidthRatio:
input.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
mansardSteepWidthRatio:
input.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio,
dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
dutchWaistLengthRatio: input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
dutchGabletRake: input.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake,
}
}
export type DutchRoofShapeMetrics = {
axis: 'width' | 'depth'
inset: number
middleHeight: number
peakHeight: number
rakeReach: number
innerWaistHalfX: number
innerWaistHalfZ: number
outerWaistHalfX: number
outerWaistHalfZ: number
}
export function getDutchRoofShapeMetrics(input: {
w: number
d: number
wh: number
rh: number
dutchI?: number
baseW: number
baseD: number
shapeRatios: RoofShapeRatios
}): DutchRoofShapeMetrics | null {
const fallbackInset = Math.min(input.baseW, input.baseD) * input.shapeRatios.dutchHipWidthRatio
const maxI = Math.max(0, Math.min(input.w, input.d) / 2 - 0.005)
const inset = Math.min(Math.max(0, input.dutchI ?? fallbackInset), maxI)
const peakHeight = input.wh + Math.max(0.001, input.rh)
const middleHeight = input.wh + input.rh * input.shapeRatios.dutchHipHeightRatio
if (!(inset > 0.001) || !(peakHeight > middleHeight + 0.001)) return null
if (input.w >= input.d) {
const innerWaistHalfX = Math.max(
0,
(input.w / 2 - inset) * input.shapeRatios.dutchWaistLengthRatio,
)
const innerWaistHalfZ = Math.max(0, input.d / 2 - inset)
if (!(innerWaistHalfX > 0.001 && innerWaistHalfZ > 0.001)) return null
const rakeReach = Math.min(
Math.max(0, input.shapeRatios.dutchGabletRake),
Math.max(0, input.w / 2 - innerWaistHalfX) * 0.98,
)
return {
axis: 'width',
inset,
middleHeight,
peakHeight,
rakeReach,
innerWaistHalfX,
innerWaistHalfZ,
outerWaistHalfX: innerWaistHalfX + rakeReach,
outerWaistHalfZ: innerWaistHalfZ,
}
}
const innerWaistHalfX = Math.max(0, input.w / 2 - inset)
const innerWaistHalfZ = Math.max(
0,
(input.d / 2 - inset) * input.shapeRatios.dutchWaistLengthRatio,
)
if (!(innerWaistHalfX > 0.001 && innerWaistHalfZ > 0.001)) return null
const rakeReach = Math.min(
Math.max(0, input.shapeRatios.dutchGabletRake),
Math.max(0, input.d / 2 - innerWaistHalfZ) * 0.98,
)
return {
axis: 'depth',
inset,
middleHeight,
peakHeight,
rakeReach,
innerWaistHalfX,
innerWaistHalfZ,
outerWaistHalfX: innerWaistHalfX,
outerWaistHalfZ: innerWaistHalfZ + rakeReach,
}
}
export function getRoofShapeInsets(input: {
roofType: RoofType
width: number
depth: number
wh: number
baseY: number
isVoid: boolean
brushW: number
brushD: number
tanTheta: number
shingleThickness: number
dutchHipWidthRatio: number
}): RoofShapeInsets {
let inset = (input.wh - input.baseY) * input.tanTheta
const maxSafeInset = Math.min(input.brushW, input.brushD) / 2 - 0.005
if (inset > maxSafeInset) inset = maxSafeInset
let iF = 0
let iB = 0
let iL = 0
let iR = 0
if (input.roofType === 'hip' || input.roofType === 'mansard' || input.roofType === 'dutch') {
iF = inset
iB = inset
iL = inset
iR = inset
} else if (input.roofType === 'gable' || input.roofType === 'gambrel') {
iF = inset
iB = inset
} else if (input.roofType === 'shed') {
iF = inset
}
let dutchI = Math.min(input.width, input.depth) * input.dutchHipWidthRatio
if (input.isVoid) dutchI += input.shingleThickness
return { iF, iB, iL, iR, dutchI }
}
export function getDutchEndSlopeFaces(input: {
w: number
d: number
wh: number
rh: number
insets: RoofShapeInsets
baseW: number
baseD: number
shapeRatios: RoofShapeRatios
dutchTopRakeThickness?: number
}): RoofShapeFaceVertex[][] {
const dutch = getDutchRoofShapeMetrics({
w: input.w,
d: input.d,
wh: input.wh,
rh: input.rh,
dutchI: input.insets.dutchI,
baseW: input.baseW,
baseD: input.baseD,
shapeRatios: input.shapeRatios,
})
if (!dutch) return []
const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z })
const e1 = v(-input.w / 2, input.wh, input.d / 2)
const e2 = v(input.w / 2, input.wh, input.d / 2)
const e3 = v(input.w / 2, input.wh, -input.d / 2)
const e4 = v(-input.w / 2, input.wh, -input.d / 2)
// The hip end slope is constructed only up to the outer waist (rake) line.
// Extend its top edge inward along the same hip rulings until it reaches the
// gablet's inner triangle (the inner waist line), continuing each
// eave→outer-waist ridge line so the face stays planar and the pitch is
// unchanged — the edge climbs past middleHeight and meets the gablet face.
const lerp = (a: RoofShapeFaceVertex, b: RoofShapeFaceVertex, t: number): RoofShapeFaceVertex =>
v(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t)
const lowerAlongY = (point: RoofShapeFaceVertex): RoofShapeFaceVertex =>
v(point.x, point.y - Math.max(0, input.dutchTopRakeThickness ?? 0), point.z)
if (dutch.axis === 'width') {
const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
const r1 = v(-dutch.innerWaistHalfX, input.wh + input.rh, 0)
const r2 = v(dutch.innerWaistHalfX, input.wh + input.rh, 0)
const projectWidthAxisPointToEndSlope = (
point: RoofShapeFaceVertex,
side: 1 | -1,
): RoofShapeFaceVertex => {
const denomY = dutch.middleHeight - input.wh
const tPlane = Math.abs(denomY) > 1e-9 ? (point.y - input.wh) / denomY : 0
const x = side * (input.w / 2 + (dutch.outerWaistHalfX - input.w / 2) * tPlane)
return v(x, point.y, point.z)
}
const top2 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m2, r2, 0.5)), 1)
const top3 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m3, r2, 0.5)), 1)
const top1 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m1, r1, 0.5)), -1)
const top4 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m4, r1, 0.5)), -1)
return [
[e2, e3, o3, top3, top2, o2],
[e4, e1, o1, top1, top4, o4],
]
}
const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ)
const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ)
const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ)
const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ)
const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
const r1 = v(0, input.wh + input.rh, dutch.innerWaistHalfZ)
const r2 = v(0, input.wh + input.rh, -dutch.innerWaistHalfZ)
const projectDepthAxisPointToEndSlope = (
point: RoofShapeFaceVertex,
side: 1 | -1,
): RoofShapeFaceVertex => {
const denomY = dutch.middleHeight - input.wh
const tPlane = Math.abs(denomY) > 1e-9 ? (point.y - input.wh) / denomY : 0
const z = side * (input.d / 2 + (dutch.outerWaistHalfZ - input.d / 2) * tPlane)
return v(point.x, point.y, z)
}
const top2 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m2, r1, 0.5)), 1)
const top1 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m1, r1, 0.5)), 1)
const top4 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m4, r2, 0.5)), -1)
const top3 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m3, r2, 0.5)), -1)
return [
[e1, e2, o2, top2, top1, o1],
[e3, e4, o4, top4, top3, o3],
]
}
export function getRoofModuleFaces(input: {
type: RoofType
w: number
d: number
wh: number
rh: number
baseY: number
insets: RoofShapeInsets
baseW: number
baseD: number
tanTheta: number
shapeRatios: RoofShapeRatios
excludeDutchEndSlopes?: boolean
dutchTopRakeThickness?: number
}): RoofShapeFaceVertex[][] {
const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z })
const { iF = 0, iB = 0, iL = 0, iR = 0 } = input.insets
const b1 = v(-input.w / 2 + iL, input.baseY, input.d / 2 - iF)
const b2 = v(input.w / 2 - iR, input.baseY, input.d / 2 - iF)
const b3 = v(input.w / 2 - iR, input.baseY, -input.d / 2 + iB)
const b4 = v(-input.w / 2 + iL, input.baseY, -input.d / 2 + iB)
const bottom = [b4, b3, b2, b1]
const e1 = v(-input.w / 2, input.wh, input.d / 2)
const e2 = v(input.w / 2, input.wh, input.d / 2)
const e3 = v(input.w / 2, input.wh, -input.d / 2)
const e4 = v(-input.w / 2, input.wh, -input.d / 2)
const faces: RoofShapeFaceVertex[][] = []
faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom)
const h = input.wh + Math.max(0.001, input.rh)
if (input.type === 'flat' || input.rh === 0) {
faces.push([e1, e2, e3, e4])
} else if (input.type === 'gable') {
const r1 = v(-input.w / 2, h, 0)
const r2 = v(input.w / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else if (input.type === 'hip') {
if (Math.abs(input.w - input.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 (input.w >= input.d) {
const r1 = v(-input.w / 2 + input.d / 2, h, 0)
const r2 = v(input.w / 2 - input.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, input.d / 2 - input.w / 2)
const r2 = v(0, h, -input.d / 2 + input.w / 2)
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])
}
} else if (input.type === 'shed') {
const t1 = v(-input.w / 2, h, -input.d / 2)
const t2 = v(input.w / 2, h, -input.d / 2)
faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1])
} else if (input.type === 'gambrel') {
const mz = (input.baseD / 2) * input.shapeRatios.gambrelLowerWidthRatio
const dist = input.d / 2 - mz
const mh = input.wh + dist * (input.tanTheta || 0)
const m1 = v(-input.w / 2, mh, mz)
const m2 = v(input.w / 2, mh, mz)
const m3 = v(input.w / 2, mh, -mz)
const m4 = v(-input.w / 2, mh, -mz)
const r1 = v(-input.w / 2, h, 0)
const r2 = v(input.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 (input.type === 'mansard') {
const i = Math.min(input.baseW, input.baseD) * input.shapeRatios.mansardSteepWidthRatio
const mh = input.wh + i * (input.tanTheta || 0)
const m1 = v(-input.w / 2 + i, mh, input.d / 2 - i)
const m2 = v(input.w / 2 - i, mh, input.d / 2 - i)
const m3 = v(input.w / 2 - i, mh, -input.d / 2 + i)
const m4 = v(-input.w / 2 + i, mh, -input.d / 2 + i)
const topW = input.w - i * 2
const topD = input.d - i * 2
faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4])
if (Math.abs(topW - topD) < 0.01) {
const r = v(0, h, 0)
faces.push([m4, m1, r], [m1, m2, r], [m2, m3, r], [m3, m4, r])
} else if (topW >= topD) {
const r1 = v(-topW / 2 + topD / 2, h, 0)
const r2 = v(topW / 2 - topD / 2, h, 0)
faces.push([m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2])
} else {
const r1 = v(0, h, topD / 2 - topW / 2)
const r2 = v(0, h, -topD / 2 + topW / 2)
faces.push([m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2])
}
} else if (input.type === 'dutch') {
const dutch = getDutchRoofShapeMetrics({
w: input.w,
d: input.d,
wh: input.wh,
rh: input.rh,
dutchI: input.insets.dutchI,
baseW: input.baseW,
baseD: input.baseD,
shapeRatios: input.shapeRatios,
})
if (!dutch) return faces
const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
if (dutch.axis === 'width') {
const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
const r1 = v(-dutch.innerWaistHalfX, h, 0)
const r2 = v(dutch.innerWaistHalfX, h, 0)
const endSlopes = input.excludeDutchEndSlopes
? []
: getDutchEndSlopeFaces({
w: input.w,
d: input.d,
wh: input.wh,
rh: input.rh,
insets: input.insets,
baseW: input.baseW,
baseD: input.baseD,
shapeRatios: input.shapeRatios,
dutchTopRakeThickness: input.dutchTopRakeThickness,
})
faces.push([e1, e2, o2, m2, m1, o1], [e3, e4, o4, m4, m3, o3])
if (endSlopes.length === 2) {
faces.push(...endSlopes)
} else if (!input.excludeDutchEndSlopes) {
faces.push([e2, e3, o3, o2], [e4, e1, o1, o4])
}
faces.push([m1, m2, r2, r1], [m3, m4, r1, r2])
faces.push([m4, m1, r1], [m2, m3, r2])
} else {
const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ)
const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ)
const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ)
const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ)
const r1 = v(0, h, dutch.innerWaistHalfZ)
const r2 = v(0, h, -dutch.innerWaistHalfZ)
const endSlopes = input.excludeDutchEndSlopes
? []
: getDutchEndSlopeFaces({
w: input.w,
d: input.d,
wh: input.wh,
rh: input.rh,
insets: input.insets,
baseW: input.baseW,
baseD: input.baseD,
shapeRatios: input.shapeRatios,
dutchTopRakeThickness: input.dutchTopRakeThickness,
})
faces.push([e2, e3, o3, m3, m2, o2], [e4, e1, o1, m1, m4, o4])
if (endSlopes.length === 2) {
faces.push(...endSlopes)
} else if (!input.excludeDutchEndSlopes) {
faces.push([e1, e2, o2, o1], [e3, e4, o4, o3])
}
faces.push([m2, m3, r2, r1], [m4, m1, r1, r2])
faces.push([m1, m2, r1], [m3, m4, r2])
}
}
return faces
}
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test'
import { getRoofSegmentSurfaceY, ROOF_SHAPE_DEFAULTS, RoofSegmentNode } from './roof-segment'
describe('getRoofSegmentSurfaceY', () => {
test('keeps the Dutch width-axis rake on the upper gable slope', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
wallHeight: 3,
pitch: 40,
})
const activeRh = getRoofSegmentSurfaceY(segment, 0, 0) - segment.wallHeight
const upperRise = activeRh * (1 - segment.dutchHipHeightRatio)
const waistHalfZ =
segment.depth / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio
const availableRake = Math.max(
0,
segment.width / 2 -
(segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) *
segment.dutchWaistLengthRatio,
)
const rakeReach = Math.min(segment.dutchGabletRake, availableRake * 0.98)
const localX =
(segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) *
segment.dutchWaistLengthRatio +
rakeReach * 0.5
const localZ = waistHalfZ * 0.5
const expected = segment.wallHeight + activeRh - localZ * (upperRise / waistHalfZ)
expect(localX).toBeGreaterThan(
(segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) *
segment.dutchWaistLengthRatio,
)
expect(getRoofSegmentSurfaceY(segment, localX, localZ)).toBeCloseTo(expected, 6)
})
test('keeps the Dutch depth-axis rake on the upper gable slope', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 6,
depth: 8,
wallHeight: 3,
pitch: 40,
})
const activeRh = getRoofSegmentSurfaceY(segment, 0, 0) - segment.wallHeight
const upperRise = activeRh * (1 - segment.dutchHipHeightRatio)
const waistHalfX =
segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio
const innerWaistHalfZ =
(segment.depth / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) *
segment.dutchWaistLengthRatio
const rakeReach = Math.min(
segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake,
Math.max(0, segment.depth / 2 - innerWaistHalfZ) * 0.98,
)
const localX = waistHalfX * 0.5
const localZ = innerWaistHalfZ + rakeReach * 0.5
const expected = segment.wallHeight + activeRh - localX * (upperRise / waistHalfX)
expect(localZ).toBeGreaterThan(innerWaistHalfZ)
expect(getRoofSegmentSurfaceY(segment, localX, localZ)).toBeCloseTo(expected, 6)
})
})
@@ -0,0 +1,126 @@
import { describe, expect, test } from 'bun:test'
import {
getRoofSegmentVisibleTopBounds,
normalizeRoofSegmentTrim,
RoofSegmentNode,
} from './roof-segment'
describe('roof segment trim', () => {
test('defaults legacy segments to no trim', () => {
const segment = RoofSegmentNode.parse({
id: 'rseg_test',
type: 'roof-segment',
})
expect(segment.trim).toEqual({
left: 0,
right: 0,
front: 0,
back: 0,
frontLeft: 0,
frontRight: 0,
backLeft: 0,
backRight: 0,
frontLeftX: 0,
frontLeftZ: 0,
frontRightX: 0,
frontRightZ: 0,
backLeftX: 0,
backLeftZ: 0,
backRightX: 0,
backRightZ: 0,
})
})
test('normalizes impossible side totals without inverting the footprint', () => {
const trim = normalizeRoofSegmentTrim({
width: 4,
depth: 3,
trim: { left: 3, right: 3, front: 2, back: 2 },
})
expect(trim.left + trim.right).toBeCloseTo(3.9)
expect(trim.front + trim.back).toBeCloseTo(2.9)
})
test('visible top bounds respect asymmetric trims', () => {
const segment = RoofSegmentNode.parse({
id: 'rseg_test',
type: 'roof-segment',
width: 8,
depth: 6,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
trim: { left: 1, right: 2, front: 0, back: 0 },
})
const bounds = getRoofSegmentVisibleTopBounds(segment)
expect(bounds.minX).toBeCloseTo(-3)
expect(bounds.maxX).toBeCloseTo(2)
expect(bounds.width).toBeCloseTo(5)
})
test('visible top bounds stay finite for legacy partial segments', () => {
const bounds = getRoofSegmentVisibleTopBounds({
id: 'rseg_legacy',
type: 'roof-segment',
roofType: 'gable',
trim: { left: 1 },
} as unknown as Parameters<typeof getRoofSegmentVisibleTopBounds>[0])
expect(Number.isFinite(bounds.minX)).toBe(true)
expect(Number.isFinite(bounds.maxX)).toBe(true)
expect(Number.isFinite(bounds.minZ)).toBe(true)
expect(Number.isFinite(bounds.maxZ)).toBe(true)
expect(Number.isFinite(bounds.width)).toBe(true)
expect(Number.isFinite(bounds.depth)).toBe(true)
})
test('diagonal trims can shorten the ridge span', () => {
const segment = RoofSegmentNode.parse({
id: 'rseg_test',
type: 'roof-segment',
width: 8,
depth: 6,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
trim: { left: 0, right: 0, front: 0, back: 0, frontLeft: 4, backRight: 4 },
})
const bounds = getRoofSegmentVisibleTopBounds(segment)
expect(bounds.minX).toBeCloseTo(-3)
expect(bounds.maxX).toBeCloseTo(3)
expect(bounds.width).toBeCloseTo(6)
})
test('diagonal trims support independent width and depth endpoints', () => {
const segment = RoofSegmentNode.parse({
id: 'rseg_test',
type: 'roof-segment',
width: 8,
depth: 6,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
trim: {
frontLeftX: 5,
frontLeftZ: 2,
backRightX: 2,
backRightZ: 5,
},
})
const trim = normalizeRoofSegmentTrim(segment)
const bounds = getRoofSegmentVisibleTopBounds(segment)
expect(trim.frontLeft).toBeCloseTo(2)
expect(trim.frontLeftX).toBeCloseTo(5)
expect(trim.frontLeftZ).toBeCloseTo(2)
expect(bounds.maxZ).toBeCloseTo(2.6)
expect(bounds.maxX).toBeCloseTo(3.2)
})
})
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
import { RoofSegmentNode } from './roof-segment'
import {
getRoofSegmentWallFace,
getRoofSegmentWallFaces,
getRoofWallFaceFrame,
roofFacePointToSegment,
segmentPointToRoofWallFace,
@@ -75,4 +76,39 @@ describe('roof wall face frames', () => {
expect(getRoofWallFaceFrame(seg, faceId).yaw).toBe(getRoofSegmentWallFace(seg, faceId).yaw)
}
})
test('dutch width-axis roofs expose hostable gable-end wall profiles on the short ends', () => {
const faces = getRoofSegmentWallFaces(
segment({
roofType: 'dutch',
width: 8,
depth: 6,
dutchHipWidthRatio: 0.25,
dutchWaistLengthRatio: 1,
}),
)
const left = faces.find((face) => face.id === 'left')
const right = faces.find((face) => face.id === 'right')
expect(left?.profile.length).toBeGreaterThan(5)
expect(right?.profile.length).toBeGreaterThan(5)
expect(left?.profile[3]?.[1]).toBeCloseTo(left?.profile[2]?.[1] ?? 0)
})
test('dutch long-side faces stay rectangular while only the gable ends rise above the eave', () => {
const faces = getRoofSegmentWallFaces(
segment({
roofType: 'dutch',
width: 8,
depth: 6,
}),
)
const front = faces.find((face) => face.id === 'front')
const back = faces.find((face) => face.id === 'back')
expect(front?.profile).toHaveLength(4)
expect(back?.profile).toHaveLength(4)
})
})
@@ -1,5 +1,5 @@
import type { RoofSegmentNode } from './roof-segment'
import { getSegmentSlopeFrame } from './roof-segment'
import { getDutchRoofMetrics, getSegmentSlopeFrame } from './roof-segment'
/**
* Wall-face math for roof segments — the vertical surfaces a wall-mounted
@@ -53,6 +53,7 @@ type SegmentWallInputs = Pick<
| 'mansardSteepHeightRatio'
| 'dutchHipWidthRatio'
| 'dutchHipHeightRatio'
| 'dutchWaistLengthRatio'
>
>
@@ -173,8 +174,31 @@ function buildFaceProfile(
[0, peakY],
]
}
// hip / mansard / dutch slope on every side (dutch gablets are
// recessed from the wall plane), so only the base rect is placeable.
case 'dutch': {
const metrics = getDutchRoofMetrics(node)
const isDutchGableFace =
(metrics.axis === 'x' && isEnd) ||
(metrics.axis === 'z' && (id === 'front' || id === 'back'))
if (!isDutchGableFace) return rectProfile(length, eaveY)
const shoulderInset =
metrics.axis === 'x' ? metrics.shoulderInsetAlongDepth : metrics.shoulderInsetAlongWidth
if (!(shoulderInset > 0.001)) return rectProfile(length, eaveY)
const shoulderLo = Math.max(0, shoulderInset)
const shoulderHi = Math.min(length, length - shoulderInset)
if (!(shoulderHi - shoulderLo > 0.02)) return rectProfile(length, eaveY)
return [
[0, 0],
[length, 0],
[length, eaveY],
[shoulderHi, eaveY],
[length / 2, peakY],
[shoulderLo, eaveY],
[0, eaveY],
]
}
default:
return rectProfile(length, eaveY)
}
+383 -7
View File
@@ -8,6 +8,50 @@ export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'man
export type RoofType = z.infer<typeof RoofType>
export const MIN_ROOF_SEGMENT_TRIM_SPAN = 0.1
const DEFAULT_ROOF_SEGMENT_WIDTH = 8
const DEFAULT_ROOF_SEGMENT_DEPTH = 6
export const RoofSegmentTrim = z
.object({
left: z.number().min(0).default(0),
right: z.number().min(0).default(0),
front: z.number().min(0).default(0),
back: z.number().min(0).default(0),
frontLeft: z.number().min(0).default(0),
frontRight: z.number().min(0).default(0),
backLeft: z.number().min(0).default(0),
backRight: z.number().min(0).default(0),
frontLeftX: z.number().min(0).default(0),
frontLeftZ: z.number().min(0).default(0),
frontRightX: z.number().min(0).default(0),
frontRightZ: z.number().min(0).default(0),
backLeftX: z.number().min(0).default(0),
backLeftZ: z.number().min(0).default(0),
backRightX: z.number().min(0).default(0),
backRightZ: z.number().min(0).default(0),
})
.default({
left: 0,
right: 0,
front: 0,
back: 0,
frontLeft: 0,
frontRight: 0,
backLeft: 0,
backRight: 0,
frontLeftX: 0,
frontLeftZ: 0,
frontRightX: 0,
frontRightZ: 0,
backLeftX: 0,
backLeftZ: 0,
backRightX: 0,
backRightZ: 0,
})
export type RoofSegmentTrim = z.infer<typeof RoofSegmentTrim>
// Default shape ratios. Tuning these used to require editing the geometry
// code in two places; they are now schema fields with these defaults.
export const ROOF_SHAPE_DEFAULTS = {
@@ -23,6 +67,17 @@ export const ROOF_SHAPE_DEFAULTS = {
dutchHipWidthRatio: 0.25,
/** Dutch: hip face rises this fraction of the way to the peak. */
dutchHipHeightRatio: 0.5,
/** Dutch: gable waist span along the ridge axis, as a fraction of the max span. */
dutchWaistLengthRatio: 0.98,
/**
* Dutch: how far the gablet's barge board extends outward past the gablet
* end-wall, along the ridge axis, in metres. 0 disables the rake. The board
* lies in the gablet's slope planes (coplanar with the main Dutch slopes)
* and overhangs the lower hip skirt; the gablet end-wall itself stays put.
*/
dutchGabletRake: 0.48,
/** Dutch: thickness of the top gable rake slab. */
dutchTopRakeThickness: 0.21,
} as const
export const RoofSegmentNode = BaseNode.extend({
@@ -51,6 +106,10 @@ export const RoofSegmentNode = BaseNode.extend({
// Footprint dimensions
width: z.number().default(8),
depth: z.number().default(6),
// Segment-local distances trimmed from each footprint side. The trim
// boundary is projected vertically through the roof volume, so the
// resulting edge follows the actual sloped roof surfaces.
trim: RoofSegmentTrim,
// Wall height beneath the roof
wallHeight: z.number().default(0.5),
// Roof pitch in degrees — angle of the primary slope face.
@@ -96,6 +155,17 @@ export const RoofSegmentNode = BaseNode.extend({
.min(0.1)
.max(0.9)
.default(ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio),
dutchWaistLengthRatio: z
.number()
.min(0.1)
.max(1)
.default(ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio),
dutchGabletRake: z.number().min(0).max(3).default(ROOF_SHAPE_DEFAULTS.dutchGabletRake),
dutchTopRakeThickness: z
.number()
.min(0.01)
.max(0.5)
.default(ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness),
// Hosted accessories — chimney, dormer, skylight, box-vent,
// ridge-vent, solar-panel, gutter. Each accessory's `parentId` points back
// here; the segment renderer mounts them recursively via
@@ -112,6 +182,7 @@ export const RoofSegmentNode = BaseNode.extend({
Multiple segments can be combined to form complex roof shapes.
- roofType: hip, gable, shed, gambrel, dutch, mansard, flat
- width/depth: footprint dimensions
- trim: segment-local side cut distances
- wallHeight: height of walls below the roof
- pitch: roof slope in degrees (angle of the primary slope face)
- wallThickness/deckThickness: structural thicknesses
@@ -120,11 +191,131 @@ export const RoofSegmentNode = BaseNode.extend({
- gambrelLowerWidthRatio / gambrelLowerHeightRatio: kink position on gambrel roofs
- mansardSteepWidthRatio / mansardSteepHeightRatio: waist position on mansard roofs
- dutchHipWidthRatio / dutchHipHeightRatio: hip-to-gable split on dutch roofs
- dutchWaistLengthRatio: gable waist span along the ridge axis
- dutchGabletRake: gablet barge-board overhang past the gablet end-wall (m, 0 = none)
- dutchTopRakeThickness: thickness of the top gable rake slab
`,
)
export type RoofSegmentNode = z.infer<typeof RoofSegmentNode>
function finiteNonNegative(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
}
function finitePositive(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
}
function normalizeTrimAxis(start: unknown, end: unknown, span: number): readonly [number, number] {
const maxTotal = Math.max(0, finiteNonNegative(span) - MIN_ROOF_SEGMENT_TRIM_SPAN)
let a = Math.min(finiteNonNegative(start), maxTotal)
let b = Math.min(finiteNonNegative(end), maxTotal)
const total = a + b
if (total > maxTotal && total > 0) {
const scale = maxTotal / total
a *= scale
b *= scale
}
return [a, b] as const
}
export function normalizeRoofSegmentTrim(
node: Pick<RoofSegmentNode, 'width' | 'depth'> & { trim?: Partial<RoofSegmentTrim> },
): RoofSegmentTrim {
const trim = node.trim ?? {}
const [left, right] = normalizeTrimAxis(trim.left, trim.right, node.width)
const [back, front] = normalizeTrimAxis(trim.back, trim.front, node.depth)
const maxWidthDiagonal = Math.max(0, finiteNonNegative(node.width) - left - right)
const maxDepthDiagonal = Math.max(0, finiteNonNegative(node.depth) - front - back)
const maxWidthPair = Math.max(0, maxWidthDiagonal - MIN_ROOF_SEGMENT_TRIM_SPAN)
const maxDepthPair = Math.max(0, maxDepthDiagonal - MIN_ROOF_SEGMENT_TRIM_SPAN)
let [frontLeftX, frontLeftZ] = normalizeCornerAxisTrim(
trim.frontLeft,
trim.frontLeftX,
trim.frontLeftZ,
maxWidthPair,
maxDepthPair,
)
let [frontRightX, frontRightZ] = normalizeCornerAxisTrim(
trim.frontRight,
trim.frontRightX,
trim.frontRightZ,
maxWidthPair,
maxDepthPair,
)
let [backLeftX, backLeftZ] = normalizeCornerAxisTrim(
trim.backLeft,
trim.backLeftX,
trim.backLeftZ,
maxWidthPair,
maxDepthPair,
)
let [backRightX, backRightZ] = normalizeCornerAxisTrim(
trim.backRight,
trim.backRightX,
trim.backRightZ,
maxWidthPair,
maxDepthPair,
)
for (let i = 0; i < 3; i += 1) {
;[frontLeftX, frontRightX] = normalizeTrimPair(frontLeftX, frontRightX, maxWidthPair)
;[backLeftX, backRightX] = normalizeTrimPair(backLeftX, backRightX, maxWidthPair)
;[frontLeftZ, backLeftZ] = normalizeTrimPair(frontLeftZ, backLeftZ, maxDepthPair)
;[frontRightZ, backRightZ] = normalizeTrimPair(frontRightZ, backRightZ, maxDepthPair)
}
const frontLeft = Math.min(frontLeftX, frontLeftZ)
const frontRight = Math.min(frontRightX, frontRightZ)
const backLeft = Math.min(backLeftX, backLeftZ)
const backRight = Math.min(backRightX, backRightZ)
return {
left,
right,
front,
back,
frontLeft,
frontRight,
backLeft,
backRight,
frontLeftX,
frontLeftZ,
frontRightX,
frontRightZ,
backLeftX,
backLeftZ,
backRightX,
backRightZ,
}
}
function normalizeTrimPair(a: number, b: number, maxTotal: number): [number, number] {
const total = a + b
if (total <= maxTotal || total <= 0) return [a, b]
const scale = maxTotal / total
return [a * scale, b * scale]
}
function normalizeCornerAxisTrim(
scalar: unknown,
axisX: unknown,
axisZ: unknown,
maxX: number,
maxZ: number,
): [number, number] {
const x = finiteNonNegative(axisX)
const z = finiteNonNegative(axisZ)
const fallback = finiteNonNegative(scalar)
if (x > 0 || z > 0) {
return [Math.min(x, maxX), Math.min(z, maxZ)]
}
return [Math.min(fallback, maxX), Math.min(fallback, maxZ)]
}
// ----------------------------------------------------------------------------
// Pitch ↔ roof-peak height
//
@@ -144,6 +335,7 @@ type ShapeRatios = {
mansardSteepHeightRatio: number
dutchHipWidthRatio: number
dutchHipHeightRatio: number
dutchWaistLengthRatio: number
}
type PitchInputs = {
@@ -152,9 +344,44 @@ type PitchInputs = {
depth: number
} & Partial<ShapeRatios>
export type DutchRoofMetrics = {
axis: 'x' | 'z'
inset: number
waistHalfX: number
waistHalfZ: number
ridgeStart: readonly [number, number]
ridgeEnd: readonly [number, number]
shoulderInsetAlongDepth: number
shoulderInsetAlongWidth: number
}
function getDutchUpperShellBounds(
node: Pick<RoofSegmentNode, 'width' | 'depth'> &
Partial<
Pick<RoofSegmentNode, 'dutchHipWidthRatio' | 'dutchWaistLengthRatio' | 'dutchGabletRake'>
>,
) {
const metrics = getDutchRoofMetrics(node)
const width = finitePositive(node.width, DEFAULT_ROOF_SEGMENT_WIDTH)
const depth = finitePositive(node.depth, DEFAULT_ROOF_SEGMENT_DEPTH)
const rake = node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake
const rakeReach =
metrics.axis === 'x'
? Math.min(Math.max(0, rake), Math.max(0, width / 2 - metrics.waistHalfX) * 0.98)
: Math.min(Math.max(0, rake), Math.max(0, depth / 2 - metrics.waistHalfZ) * 0.98)
return {
...metrics,
upperHalfX: metrics.axis === 'x' ? metrics.waistHalfX + rakeReach : metrics.waistHalfX,
upperHalfZ: metrics.axis === 'x' ? metrics.waistHalfZ : metrics.waistHalfZ + rakeReach,
}
}
function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios {
return {
...input,
width: finitePositive(input.width, DEFAULT_ROOF_SEGMENT_WIDTH),
depth: finitePositive(input.depth, DEFAULT_ROOF_SEGMENT_DEPTH),
gambrelLowerWidthRatio:
input.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
gambrelLowerHeightRatio:
@@ -165,6 +392,46 @@ function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios {
input.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio,
dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
dutchWaistLengthRatio: input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
}
}
export function getDutchRoofMetrics(
input: Pick<RoofSegmentNode, 'width' | 'depth'> &
Partial<Pick<RoofSegmentNode, 'dutchHipWidthRatio' | 'dutchWaistLengthRatio'>>,
): DutchRoofMetrics {
const width = finitePositive(input.width, DEFAULT_ROOF_SEGMENT_WIDTH)
const depth = finitePositive(input.depth, DEFAULT_ROOF_SEGMENT_DEPTH)
const inset =
Math.min(width, depth) * (input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio)
const waistLengthRatio = input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio
if (width >= depth) {
const waistHalfX = Math.max(0, (width / 2 - inset) * waistLengthRatio)
const waistHalfZ = Math.max(0, depth / 2 - inset)
return {
axis: 'x',
inset,
waistHalfX,
waistHalfZ,
ridgeStart: [-waistHalfX, 0],
ridgeEnd: [waistHalfX, 0],
shoulderInsetAlongDepth: Math.max(0, depth / 2 - waistHalfZ),
shoulderInsetAlongWidth: Math.max(0, width / 2 - waistHalfX),
}
}
const waistHalfX = Math.max(0, width / 2 - inset)
const waistHalfZ = Math.max(0, (depth / 2 - inset) * waistLengthRatio)
return {
axis: 'z',
inset,
waistHalfX,
waistHalfZ,
ridgeStart: [0, waistHalfZ],
ridgeEnd: [0, -waistHalfZ],
shoulderInsetAlongDepth: Math.max(0, depth / 2 - waistHalfZ),
shoulderInsetAlongWidth: Math.max(0, width / 2 - waistHalfX),
}
}
@@ -252,6 +519,86 @@ export function getActiveRoofHeight(node: Parameters<typeof getSegmentSlopeFrame
return getSegmentSlopeFrame(node).activeRh
}
export type RoofSegmentVisibleTopBounds = {
minX: number
maxX: number
minZ: number
maxZ: number
width: number
depth: number
}
export function getRoofSegmentVisibleTopBounds(
segment: RoofSegmentNode,
): RoofSegmentVisibleTopBounds {
const { activeRh, cosTheta, sinTheta } = getSegmentSlopeFrame(segment)
const width = finitePositive(segment.width, DEFAULT_ROOF_SEGMENT_WIDTH)
const depth = finitePositive(segment.depth, DEFAULT_ROOF_SEGMENT_DEPTH)
const trim = normalizeRoofSegmentTrim({ ...segment, width, depth })
const horizontalOverhang = finiteNonNegative(segment.overhang) * cosTheta
const deckExt = finiteNonNegative(segment.wallThickness) / 2 + horizontalOverhang
const shingleOverhang = finiteNonNegative(segment.shingleThickness) * sinTheta
let xExt = deckExt
let frontExt = deckExt
let backExt = deckExt
if (
segment.roofType === 'hip' ||
segment.roofType === 'mansard' ||
segment.roofType === 'dutch'
) {
xExt += shingleOverhang
frontExt += shingleOverhang
backExt += shingleOverhang
} else if (segment.roofType === 'gable' || segment.roofType === 'gambrel') {
frontExt += shingleOverhang
backExt += shingleOverhang
} else if (segment.roofType === 'shed' && activeRh > 0) {
frontExt += shingleOverhang
}
let minX = trim.left > 0 ? -width / 2 + trim.left : -width / 2 - xExt
let maxX = trim.right > 0 ? width / 2 - trim.right : width / 2 + xExt
let minZ = trim.back > 0 ? -depth / 2 + trim.back : -depth / 2 - backExt
let maxZ = trim.front > 0 ? depth / 2 - trim.front : depth / 2 + frontExt
if (trim.frontLeftX > 0 && trim.frontLeftZ > 0 && maxZ - trim.frontLeftZ < 0) {
minX = Math.max(minX, minX + (trim.frontLeftX * (trim.frontLeftZ - maxZ)) / trim.frontLeftZ)
}
if (trim.backLeftX > 0 && trim.backLeftZ > 0 && minZ + trim.backLeftZ > 0) {
minX = Math.max(minX, minX + (trim.backLeftX * (trim.backLeftZ + minZ)) / trim.backLeftZ)
}
if (trim.frontRightX > 0 && trim.frontRightZ > 0 && maxZ - trim.frontRightZ < 0) {
maxX = Math.min(maxX, maxX - (trim.frontRightX * (trim.frontRightZ - maxZ)) / trim.frontRightZ)
}
if (trim.backRightX > 0 && trim.backRightZ > 0 && minZ + trim.backRightZ > 0) {
maxX = Math.min(maxX, maxX - (trim.backRightX * (trim.backRightZ + minZ)) / trim.backRightZ)
}
if (trim.frontLeftX > 0 && trim.frontLeftZ > 0 && minX + trim.frontLeftX > 0) {
maxZ = Math.min(maxZ, maxZ - (trim.frontLeftZ * (trim.frontLeftX + minX)) / trim.frontLeftX)
}
if (trim.frontRightX > 0 && trim.frontRightZ > 0 && maxX - trim.frontRightX < 0) {
maxZ = Math.min(maxZ, maxZ - (trim.frontRightZ * (trim.frontRightX - maxX)) / trim.frontRightX)
}
if (trim.backLeftX > 0 && trim.backLeftZ > 0 && minX + trim.backLeftX > 0) {
minZ = Math.max(minZ, minZ + (trim.backLeftZ * (trim.backLeftX + minX)) / trim.backLeftX)
}
if (trim.backRightX > 0 && trim.backRightZ > 0 && maxX - trim.backRightX < 0) {
minZ = Math.max(minZ, minZ + (trim.backRightZ * (trim.backRightX - maxX)) / trim.backRightX)
}
return {
minX,
maxX,
minZ,
maxZ,
width: Math.max(0.01, maxX - minX),
depth: Math.max(0.01, maxZ - minZ),
}
}
/** Segment-local surface height used by roof accessory placement and hit disambiguation. */
export function getRoofSegmentSurfaceY(
node: Pick<RoofSegmentNode, 'roofType' | 'width' | 'depth' | 'wallHeight'> &
@@ -259,20 +606,49 @@ export function getRoofSegmentSurfaceY(
localX: number,
localZ: number,
): number {
const activeRh = getActiveRoofHeight(node)
const slopeFrame = getSegmentSlopeFrame(node)
const activeRh = slopeFrame.activeRh
const peakY = node.wallHeight + activeRh
if (activeRh === 0) return node.wallHeight
if (
node.roofType === 'gable' ||
node.roofType === 'gambrel' ||
node.roofType === 'mansard' ||
node.roofType === 'dutch'
) {
if (node.roofType === 'gable' || node.roofType === 'gambrel' || node.roofType === 'mansard') {
const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0
return peakY - t * activeRh
}
if (node.roofType === 'dutch') {
const hipHeightRatio = node.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio
const metrics = getDutchUpperShellBounds(node)
const lowerRise = activeRh * hipHeightRatio
if (metrics.axis === 'x') {
const waistHalfZ = Math.max(0.0001, metrics.waistHalfZ)
if (Math.abs(localX) <= metrics.upperHalfX && Math.abs(localZ) <= waistHalfZ) {
const upperRise = activeRh * (1 - hipHeightRatio)
const upperTan = upperRise / waistHalfZ
return peakY - Math.abs(localZ) * upperTan
}
const xProgressDenom = Math.max(0.0001, node.width / 2 - metrics.waistHalfX)
const zProgressDenom = Math.max(0.0001, node.depth / 2 - waistHalfZ)
const xProgress = Math.max(0, Math.abs(localX) - metrics.waistHalfX) / xProgressDenom
const zProgress = Math.max(0, Math.abs(localZ) - waistHalfZ) / zProgressDenom
return node.wallHeight + lowerRise * (1 - Math.min(1, Math.max(xProgress, zProgress)))
}
const waistHalfX = Math.max(0.0001, metrics.waistHalfX)
if (Math.abs(localX) <= waistHalfX && Math.abs(localZ) <= metrics.upperHalfZ) {
const upperRise = activeRh * (1 - hipHeightRatio)
const upperRun = waistHalfX
const upperTan = upperRise / upperRun
return peakY - Math.abs(localX) * upperTan
}
const xProgressDenom = Math.max(0.0001, node.width / 2 - waistHalfX)
const zProgressDenom = Math.max(0.0001, node.depth / 2 - metrics.waistHalfZ)
const xProgress = Math.max(0, Math.abs(localX) - waistHalfX) / xProgressDenom
const zProgress = Math.max(0, Math.abs(localZ) - metrics.waistHalfZ) / zProgressDenom
return node.wallHeight + lowerRise * (1 - Math.min(1, Math.max(xProgress, zProgress)))
}
if (node.roofType === 'shed') {
const t = (localZ + node.depth / 2) / (node.depth || 1)
return peakY - t * activeRh
@@ -3,8 +3,12 @@ import {
type AnyNode,
type AnyNodeId,
AnyNode as AnyNodeSchema,
createDefaultRidgeVentsForSegment,
getEffectiveWallSurfaceMaterial,
getWallSurfaceMaterialSignature,
isAutoRidgeVentEnabled,
isDefaultRidgeVentNode,
type RoofSegmentNode,
type WallNode,
} from '../../schema'
import type { CollectionId } from '../../schema/collections'
@@ -24,6 +28,21 @@ type WallMergePlan = {
attachmentUpdates: WallAttachmentUpdate[]
}
const DEFAULT_RIDGE_VENT_REFRESH_FIELDS = new Set<string>([
'roofType',
'width',
'depth',
'pitch',
'overhang',
'wallThickness',
'shingleThickness',
'gambrelLowerWidthRatio',
'mansardSteepWidthRatio',
'dutchHipWidthRatio',
'dutchWaistLengthRatio',
'dutchGabletRake',
])
type ZodCheckLike = {
_zod?: {
def?: {
@@ -496,6 +515,44 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode
return { ...currentNode, ...(sanitized.value as Partial<AnyNode>) } as AnyNode
}
function shouldRefreshDefaultRidgeVents(data: Partial<AnyNode>) {
return Object.keys(data).some((key) => DEFAULT_RIDGE_VENT_REFRESH_FIELDS.has(key))
}
function refreshDefaultRidgeVentsForSegment(
nextNodes: Record<AnyNodeId, AnyNode>,
segment: RoofSegmentNode,
): AnyNodeId[] {
const childIds = Array.isArray(segment.children) ? (segment.children as AnyNodeId[]) : []
if (!isAutoRidgeVentEnabled(segment, nextNodes)) return []
const defaultIds = childIds.filter((childId) =>
isDefaultRidgeVentNode(nextNodes[childId], segment.id),
)
const defaultIdSet = new Set(defaultIds)
for (const id of defaultIds) {
delete nextNodes[id]
}
const nextVents = createDefaultRidgeVentsForSegment(segment)
for (const vent of nextVents) {
nextNodes[vent.id as AnyNodeId] = {
...vent,
parentId: segment.id,
} as AnyNode
}
nextNodes[segment.id as AnyNodeId] = {
...segment,
children: [
...childIds.filter((childId) => !defaultIdSet.has(childId)),
...nextVents.map((vent) => vent.id as AnyNodeId),
],
} as AnyNode
return nextVents.map((vent) => vent.id as AnyNodeId)
}
// Track pending RAF for updateNodesAction to prevent multiple queued callbacks
let pendingRafId: number | null = null
let pendingUpdates: Set<AnyNodeId> = new Set()
@@ -808,6 +865,11 @@ export const applyNodeChangesAction = (
}
nextNodes[id] = updatedNode
if (updatedNode.type === 'roof-segment' && shouldRefreshDefaultRidgeVents(data)) {
for (const ventId of refreshDefaultRidgeVentsForSegment(nextNodes, updatedNode)) {
nodesToMarkDirty.add(ventId)
}
}
nodesToMarkDirty.add(id)
}
@@ -905,6 +967,7 @@ export const updateNodesAction = (
) => {
if (get().readOnly) return
const parentsToUpdate = new Set<AnyNodeId>()
const extraNodesToUpdate = new Set<AnyNodeId>()
set((state) => {
const nextNodes = { ...state.nodes }
@@ -952,6 +1015,11 @@ export const updateNodesAction = (
// Apply the update
nextNodes[id] = updatedNode
if (updatedNode.type === 'roof-segment' && shouldRefreshDefaultRidgeVents(data)) {
for (const ventId of refreshDefaultRidgeVentsForSegment(nextNodes, updatedNode)) {
extraNodesToUpdate.add(ventId)
}
}
}
return { nodes: nextNodes }
@@ -964,6 +1032,9 @@ export const updateNodesAction = (
for (const pId of parentsToUpdate) {
pendingUpdates.add(pId)
}
for (const id of extraNodesToUpdate) {
pendingUpdates.add(id)
}
if (pendingRafId !== null) {
cancelAnimationFrame(pendingRafId)
@@ -0,0 +1,370 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { createDefaultRidgeVentsForSegment, RidgeVentNode } from '../../schema/nodes/ridge-vent'
import { RoofNode } from '../../schema/nodes/roof'
import { RoofSegmentNode } from '../../schema/nodes/roof-segment'
import type { AnyNode, AnyNodeId } from '../../schema/types'
import useScene from '../use-scene'
type RafFn = (cb: (t: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
cb: (t: number) => void,
) => {
cb(0)
return 0
}) as RafFn
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
() => {}
describe('roof segment default ridge vents', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
materials: {},
readOnly: false,
})
})
test('regenerates default ridge vents when the host ridge geometry changes', () => {
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
const segment = RoofSegmentNode.parse({
id: 'rseg_test' as never,
parentId: roof.id,
roofType: 'gable',
width: 8,
depth: 6,
})
const defaults = createDefaultRidgeVentsForSegment(segment)
const custom = RidgeVentNode.parse({
id: 'rvent_custom' as never,
parentId: segment.id,
roofSegmentId: segment.id,
name: 'Custom Ridge Vent',
position: [0, 0.2, 0],
length: 1.25,
materialPreset: 'preset-custom',
})
useScene.getState().setScene(
{
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
[segment.id]: {
...segment,
children: [...defaults.map((vent) => vent.id), custom.id],
} as AnyNode,
...Object.fromEntries(
defaults.map((vent) => [
vent.id,
{ ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode,
]),
),
[custom.id]: custom as AnyNode,
} as Record<AnyNodeId, AnyNode>,
[roof.id as AnyNodeId],
)
const oldDefaultIds = defaults.map((vent) => vent.id)
useScene.getState().updateNode(segment.id as AnyNodeId, { width: 12 } as Partial<AnyNode>)
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
| RoofSegmentNode
| undefined
const nextChildren = nextSegment?.children ?? []
const nextDefaultIds = nextChildren.filter((id) => id !== custom.id)
expect(nextChildren).toContain(custom.id)
expect(useScene.getState().nodes[custom.id as AnyNodeId]).toMatchObject({
length: 1.25,
materialPreset: 'preset-custom',
})
for (const oldId of oldDefaultIds) {
expect(useScene.getState().nodes[oldId as AnyNodeId]).toBeUndefined()
}
expect(nextDefaultIds).toHaveLength(defaults.length)
expect(
nextDefaultIds.some((id) => {
const node = useScene.getState().nodes[id as AnyNodeId]
return node?.type === 'ridge-vent' && node.length > (defaults[0]?.length ?? 0)
}),
).toBe(true)
})
test('does not regenerate default ridge vents when only trim changes', () => {
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
const segment = RoofSegmentNode.parse({
id: 'rseg_test' as never,
parentId: roof.id,
roofType: 'gable',
width: 8,
depth: 6,
})
const defaults = createDefaultRidgeVentsForSegment(segment)
useScene.getState().setScene(
{
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
[segment.id]: {
...segment,
children: defaults.map((vent) => vent.id),
} as AnyNode,
...Object.fromEntries(
defaults.map((vent) => [
vent.id,
{ ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode,
]),
),
} as Record<AnyNodeId, AnyNode>,
[roof.id as AnyNodeId],
)
const originalDefaultId = defaults[0]?.id as AnyNodeId
useScene.getState().updateNode(
segment.id as AnyNodeId,
{
trim: { ...segment.trim, left: 2, frontLeftX: 2, frontLeftZ: 3 },
} as Partial<AnyNode>,
)
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
| RoofSegmentNode
| undefined
expect(nextSegment?.children).toEqual(defaults.map((vent) => vent.id))
expect(useScene.getState().nodes[originalDefaultId]).toMatchObject({
id: originalDefaultId,
length: defaults[0]?.length,
position: defaults[0]?.position,
})
})
test('creates default ridge vents after a geometry change when auto ridge vent is enabled', () => {
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
const segment = RoofSegmentNode.parse({
id: 'rseg_test' as never,
parentId: roof.id,
roofType: 'flat',
width: 8,
depth: 6,
metadata: { autoRidgeVent: true },
})
useScene.getState().setScene(
{
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
[segment.id]: segment as AnyNode,
} as Record<AnyNodeId, AnyNode>,
[roof.id as AnyNodeId],
)
useScene.getState().updateNode(
segment.id as AnyNodeId,
{
roofType: 'gable',
} as Partial<AnyNode>,
)
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
| RoofSegmentNode
| undefined
expect(nextSegment?.children).toHaveLength(1)
expect(useScene.getState().nodes[nextSegment?.children[0] as AnyNodeId]).toMatchObject({
type: 'ridge-vent',
roofSegmentId: segment.id,
})
})
test('regenerates default ridge vents when Dutch auto-vent fields change', () => {
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
const segment = RoofSegmentNode.parse({
id: 'rseg_test' as never,
parentId: roof.id,
roofType: 'dutch',
width: 8,
depth: 6,
metadata: { autoRidgeVent: true },
})
const defaults = createDefaultRidgeVentsForSegment(segment)
useScene.getState().setScene(
{
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
[segment.id]: {
...segment,
children: defaults.map((vent) => vent.id),
} as AnyNode,
...Object.fromEntries(
defaults.map((vent) => [
vent.id,
{ ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode,
]),
),
} as Record<AnyNodeId, AnyNode>,
[roof.id as AnyNodeId],
)
const originalDefaultIds = defaults.map((vent) => vent.id)
useScene.getState().updateNode(
segment.id as AnyNodeId,
{
pitch: 52,
dutchWaistLengthRatio: 0.72,
dutchGabletRake: 0.9,
} as Partial<AnyNode>,
)
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
| RoofSegmentNode
| undefined
const nextChildren = nextSegment?.children ?? []
expect(nextChildren).toHaveLength(defaults.length)
expect(
nextChildren.some((id) =>
originalDefaultIds.includes(id as (typeof originalDefaultIds)[number]),
),
).toBe(false)
for (const oldId of originalDefaultIds) {
expect(useScene.getState().nodes[oldId as AnyNodeId]).toBeUndefined()
}
})
test('refresh replaces legacy default vents that still use preset-white', () => {
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
const segment = RoofSegmentNode.parse({
id: 'rseg_test' as never,
parentId: roof.id,
roofType: 'gable',
width: 8,
depth: 6,
metadata: { autoRidgeVent: true },
})
const legacyDefault = RidgeVentNode.parse({
id: 'rvent_legacy' as never,
parentId: segment.id,
roofSegmentId: segment.id,
name: 'Ridge Vent',
style: 'shingled',
materialPreset: 'preset-white',
position: [0, 0, 0],
length: 8,
})
useScene.getState().setScene(
{
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
[segment.id]: {
...segment,
children: [legacyDefault.id],
} as AnyNode,
[legacyDefault.id]: legacyDefault as AnyNode,
} as Record<AnyNodeId, AnyNode>,
[roof.id as AnyNodeId],
)
useScene.getState().updateNode(
segment.id as AnyNodeId,
{
pitch: 52,
} as Partial<AnyNode>,
)
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
| RoofSegmentNode
| undefined
const nextChildren = nextSegment?.children ?? []
expect(nextChildren).toHaveLength(1)
expect(nextChildren[0]).not.toBe(legacyDefault.id)
expect(useScene.getState().nodes[legacyDefault.id as AnyNodeId]).toBeUndefined()
})
test('refresh preserves user-created default-looking ridge vents without legacy preset metadata', () => {
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
const segment = RoofSegmentNode.parse({
id: 'rseg_test' as never,
parentId: roof.id,
roofType: 'gable',
width: 8,
depth: 6,
metadata: { autoRidgeVent: true },
})
const userVent = RidgeVentNode.parse({
id: 'rvent_user' as never,
parentId: segment.id,
roofSegmentId: segment.id,
name: 'Ridge Vent',
style: 'shingled',
position: [0, 0, 0],
length: 8,
})
useScene.getState().setScene(
{
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
[segment.id]: {
...segment,
children: [userVent.id],
} as AnyNode,
[userVent.id]: userVent as AnyNode,
} as Record<AnyNodeId, AnyNode>,
[roof.id as AnyNodeId],
)
useScene.getState().updateNode(
segment.id as AnyNodeId,
{
pitch: 52,
} as Partial<AnyNode>,
)
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
| RoofSegmentNode
| undefined
const nextChildren = nextSegment?.children ?? []
expect(nextChildren).toContain(userVent.id)
expect(useScene.getState().nodes[userVent.id as AnyNodeId]).toMatchObject({
id: userVent.id,
roofSegmentId: segment.id,
name: 'Ridge Vent',
style: 'shingled',
length: 8,
})
})
test('does not create default ridge vents after a geometry change when auto ridge vent is disabled', () => {
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
const segment = RoofSegmentNode.parse({
id: 'rseg_test' as never,
parentId: roof.id,
roofType: 'flat',
width: 8,
depth: 6,
metadata: { autoRidgeVent: false },
})
useScene.getState().setScene(
{
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
[segment.id]: segment as AnyNode,
} as Record<AnyNodeId, AnyNode>,
[roof.id as AnyNodeId],
)
useScene.getState().updateNode(
segment.id as AnyNodeId,
{
roofType: 'gable',
} as Partial<AnyNode>,
)
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
| RoofSegmentNode
| undefined
expect(nextSegment?.children ?? []).toHaveLength(0)
})
})
@@ -83,7 +83,7 @@ export function resolveElevatorServiceLevels(
export function getElevatorLevelHeight(levelId: string, nodes: Record<string, AnyNode>): number {
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
if (!level || level.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
let maxTop = 0
@@ -0,0 +1,61 @@
import type { FenceNode } from '../../schema'
import { getWallCurveFrameAt, getWallCurveLength, sampleWallCenterline } from '../wall/wall-curve'
import type { Point2D } from '../wall/wall-mitering'
import {
getFenceSplineFrameAt,
getFenceSplineLength,
isSplineFence,
sampleFenceSpline,
} from './fence-spline'
/**
* Unified fence centerline accessors. A fence is either:
* - a spline fence (`path` of >= 2 control points) → smooth Catmull-Rom, or
* - a straight / single-arc fence (`start`/`end` + optional `curveOffset`).
*
* These wrappers branch on `isSplineFence` and return the SAME shapes the wall
* arc helpers return, so every consumer (3D geometry, 2D floor-plan, length,
* handles) can sample the centerline without caring which kind it is. Wall arc
* math in `wall-curve.ts` is untouched — walls never carry a `path`.
*/
const DEFAULT_SAMPLE_SEGMENTS = 96
type CurveFrame = {
point: Point2D
tangent: Point2D
normal: Point2D
}
export function getFenceCenterlineFrameAt(fence: FenceNode, t: number): CurveFrame {
if (isSplineFence(fence) && fence.path) {
return getFenceSplineFrameAt(fence.path, t, fence.tangents)
}
return getWallCurveFrameAt(fence, t)
}
export function sampleFenceCenterline(
fence: FenceNode,
segments = DEFAULT_SAMPLE_SEGMENTS,
): Point2D[] {
if (isSplineFence(fence) && fence.path) {
// Spread the requested sample budget across the spans so a long path still
// reads smoothly without exploding the point count.
const spanCount = Math.max(1, fence.path.length - 1)
const perSpan = Math.max(2, Math.ceil(segments / spanCount))
return sampleFenceSpline(fence.path, fence.tangents, perSpan)
}
return sampleWallCenterline(fence, segments)
}
export function getFenceCenterlineLength(
fence: FenceNode,
segments = DEFAULT_SAMPLE_SEGMENTS,
): number {
if (isSplineFence(fence) && fence.path) {
const spanCount = Math.max(1, fence.path.length - 1)
const perSpan = Math.max(2, Math.ceil(segments / spanCount))
return getFenceSplineLength(fence.path, fence.tangents, perSpan)
}
return getWallCurveLength(fence, segments)
}
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'bun:test'
import {
getFenceControlHandle,
getFenceSplineFrameAt,
getFenceSplineLength,
getTwoPointFenceCurveTangents,
isSplineFence,
sampleFenceSpline,
} from './fence-spline'
describe('isSplineFence', () => {
test('false without a path or with < 2 points', () => {
expect(isSplineFence({ path: undefined })).toBe(false)
expect(isSplineFence({ path: [[0, 0]] })).toBe(false)
})
test('true with >= 2 points', () => {
expect(
isSplineFence({
path: [
[0, 0],
[1, 0],
],
}),
).toBe(true)
})
})
describe('sampleFenceSpline', () => {
test('honors the control points as on-curve anchors', () => {
const path: Array<[number, number]> = [
[0, 0],
[2, 2],
[4, 0],
]
const sampled = sampleFenceSpline(path, undefined, 8)
// First and last sample equal the path endpoints exactly.
expect(sampled[0]).toEqual({ x: 0, y: 0 })
expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 })
// The interior control point is interpolated: some sample lands on (2, 2).
const hitsMiddle = sampled.some((p) => Math.hypot(p.x - 2, p.y - 2) < 1e-6)
expect(hitsMiddle).toBe(true)
})
test('two-point path with no tangents is a straight segment', () => {
expect(
sampleFenceSpline(
[
[0, 0],
[3, 0],
],
undefined,
8,
),
).toEqual([
{ x: 0, y: 0 },
{ x: 3, y: 0 },
])
})
test('a stored tangent bends an otherwise straight two-point span', () => {
const path: Array<[number, number]> = [
[0, 0],
[4, 0],
]
// Pull the first point's handle up — the span should bow off the X axis.
const sampled = sampleFenceSpline(path, [[0, 2], null], 12)
expect(sampled[0]).toEqual({ x: 0, y: 0 })
expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 })
const maxY = Math.max(...sampled.map((p) => Math.abs(p.y)))
expect(maxY).toBeGreaterThan(0.1)
})
test('generated two-point curve tangents create a gentle arc', () => {
const path: Array<[number, number]> = [
[0, 0],
[4, 0],
]
const sampled = sampleFenceSpline(path, getTwoPointFenceCurveTangents(path), 16)
expect(sampled[0]).toEqual({ x: 0, y: 0 })
expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 })
const maxY = Math.max(...sampled.map((p) => p.y))
expect(maxY).toBeGreaterThan(0.4)
})
test('produces a smooth (no-cusp) curve on uneven spacing', () => {
const path: Array<[number, number]> = [
[0, 0],
[1, 0.2],
[5, 0.3],
[6, 0],
]
const sampled = sampleFenceSpline(path, undefined, 16)
let maxTurn = 0
for (let i = 2; i < sampled.length; i += 1) {
const a = sampled[i - 2]!
const b = sampled[i - 1]!
const c = sampled[i]!
const t1 = Math.atan2(b.y - a.y, b.x - a.x)
const t2 = Math.atan2(c.y - b.y, c.x - b.x)
let d = Math.abs(t2 - t1)
if (d > Math.PI) d = 2 * Math.PI - d
maxTurn = Math.max(maxTurn, d)
}
expect(maxTurn).toBeLessThan(Math.PI / 2)
})
})
describe('getFenceControlHandle', () => {
test('returns the stored tangent when present', () => {
expect(
getFenceControlHandle(
[
[0, 0],
[4, 0],
],
[[1, 2], null],
0,
),
).toEqual({ x: 1, y: 2 })
})
test('falls back to the automatic distance-aware tangent', () => {
const path: Array<[number, number]> = [
[0, 0],
[3, 0],
[6, 0],
]
expect(getFenceControlHandle(path, undefined, 1)).toEqual({ x: 1, y: 0 })
})
})
describe('getFenceSplineFrameAt', () => {
const path: Array<[number, number]> = [
[0, 0],
[2, 0],
[4, 0],
]
test('t=0 / t=1 land on the endpoints', () => {
expect(getFenceSplineFrameAt(path, 0).point).toEqual({ x: 0, y: 0 })
expect(getFenceSplineFrameAt(path, 1).point).toEqual({ x: 4, y: 0 })
})
test('returns a unit tangent and perpendicular normal', () => {
const frame = getFenceSplineFrameAt(path, 0.5)
expect(Math.hypot(frame.tangent.x, frame.tangent.y)).toBeCloseTo(1, 5)
const dot = frame.tangent.x * frame.normal.x + frame.tangent.y * frame.normal.y
expect(dot).toBeCloseTo(0, 5)
})
})
describe('getFenceSplineLength', () => {
test('roughly matches the straight distance for a straight path', () => {
expect(
getFenceSplineLength(
[
[0, 0],
[3, 4],
],
undefined,
8,
),
).toBeCloseTo(5, 5)
})
test('a curved path is longer than its endpoint chord', () => {
const path: Array<[number, number]> = [
[0, 0],
[2, 2],
[4, 0],
]
const chord = Math.hypot(4, 0)
expect(getFenceSplineLength(path, undefined, 16)).toBeGreaterThan(chord)
})
})
@@ -0,0 +1,257 @@
import type { FenceNode } from '../../schema'
import type { Point2D } from '../wall/wall-mitering'
/**
* Pure 2D spline sampling for fences whose centerline is defined by a `path`
* of control points (the "flying path" curved fence).
*
* Each control point carries an OUT-handle offset vector. When the user has
* not adjusted it, the handle defaults to a distance-aware Catmull-Rom-style
* tangent: direction comes from neighbouring points, while length is capped by
* the shorter adjacent span. When the user drags a tangent handle (stored in
* `tangents[i]`), that point's handle becomes the stored vector and the IN
* handle is its mirror, so the curve stays smooth (C1) through the point but
* bends to taste. Each span is then a cubic Bézier between consecutive points
* using their handles.
*
* Lives in `@pascal-app/core` and imports NO Three.js — the same `CurveFrame`
* shape that `wall-curve.ts` returns (point / tangent / normal) is produced
* here so the spline branch is a drop-in for the arc branch in every consumer.
*/
const EPSILON = 1e-6
const DEFAULT_SEGMENTS_PER_SPAN = 32
const TWO_POINT_CURVE_SAGITTA_RATIO = 0.18
const TWO_POINT_CURVE_MIN_SAGITTA = 0.18
const TWO_POINT_CURVE_MAX_SAGITTA = 1.2
type FenceSplineLike = Pick<FenceNode, 'path'>
type TangentList = ReadonlyArray<readonly [number, number] | null> | undefined
export function isSplineFence(fence: FenceSplineLike): boolean {
return Array.isArray(fence.path) && fence.path.length >= 2
}
type CurveFrame = {
point: Point2D
tangent: Point2D
normal: Point2D
}
function toPoints(path: ReadonlyArray<readonly [number, number]>): Point2D[] {
return path.map(([x, y]) => ({ x, y }))
}
function distance(a: Point2D, b: Point2D): number {
return Math.hypot(b.x - a.x, b.y - a.y)
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value))
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
/**
* OUT-handle offset vector for control point `index` — the stored tangent if
* the user has adjusted it, otherwise the automatic distance-aware tangent
* (endpoints duplicate the neighbour so the ends stay tangent to their single
* span). The IN handle is the negation of this.
*
* Exported so the editing UI can draw the tangent line / handle dots at the
* right place even before the user has dragged them.
*/
export function getFenceControlHandle(
path: ReadonlyArray<readonly [number, number]>,
tangents: TangentList,
index: number,
): Point2D {
const stored = tangents?.[index]
if (stored) {
return { x: stored[0], y: stored[1] }
}
const prev = path[index - 1] ?? path[index]!
const next = path[index + 1] ?? path[index]!
const prevDistance = distance(
{ x: path[index]![0], y: path[index]![1] },
{
x: prev[0],
y: prev[1],
},
)
const nextDistance = distance(
{ x: path[index]![0], y: path[index]![1] },
{
x: next[0],
y: next[1],
},
)
const handleLength = Math.min(prevDistance || nextDistance, nextDistance || prevDistance) / 3
const vx = next[0] - prev[0]
const vy = next[1] - prev[1]
const len = Math.hypot(vx, vy)
if (len < EPSILON || handleLength < EPSILON) {
return { x: 0, y: 0 }
}
return {
x: (vx / len) * handleLength,
y: (vy / len) * handleLength,
}
}
export function getTwoPointFenceCurveTangents(
path: ReadonlyArray<readonly [number, number]>,
): Array<[number, number] | null> | undefined {
if (path.length !== 2) return undefined
const start = path[0]!
const end = path[1]!
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const chordLength = Math.hypot(dx, dy)
if (chordLength < EPSILON) return undefined
const tangentX = dx / 3
const tangentY = dy / 3
const normalX = -dy / chordLength
const normalY = dx / chordLength
const sagitta = clamp(
chordLength * TWO_POINT_CURVE_SAGITTA_RATIO,
TWO_POINT_CURVE_MIN_SAGITTA,
TWO_POINT_CURVE_MAX_SAGITTA,
)
const bendX = normalX * sagitta * (4 / 3)
const bendY = normalY * sagitta * (4 / 3)
return [
[tangentX + bendX, tangentY + bendY],
[tangentX - bendX, tangentY - bendY],
]
}
function cubicBezier(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, u: number): Point2D {
const mu = 1 - u
const a = mu * mu * mu
const b = 3 * mu * mu * u
const c = 3 * mu * u * u
const d = u * u * u
return {
x: a * p0.x + b * p1.x + c * p2.x + d * p3.x,
y: a * p0.y + b * p1.y + c * p2.y + d * p3.y,
}
}
function hasAnyTangent(tangents: TangentList): boolean {
return Array.isArray(tangents) && tangents.some((t) => t != null)
}
/**
* Sample the spline centerline into a polyline. Control points are honored as
* on-curve anchors; `segmentsPerSpan` controls smoothness between them.
* Returns `(segmentsPerSpan * spanCount) + 1` points, first == path[0],
* last == path[-1].
*/
export function sampleFenceSpline(
path: ReadonlyArray<readonly [number, number]>,
tangents?: TangentList,
segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN,
): Point2D[] {
const pts = toPoints(path)
if (pts.length === 0) return []
if (pts.length === 1) return [pts[0]!]
// Two points with no adjusted tangents is a straight segment.
if (pts.length === 2 && !hasAnyTangent(tangents)) return [pts[0]!, pts[1]!]
const steps = Math.max(1, Math.floor(segmentsPerSpan))
const result: Point2D[] = [pts[0]!]
for (let i = 0; i < pts.length - 1; i += 1) {
const p1 = pts[i]!
const p2 = pts[i + 1]!
const outHandle = getFenceControlHandle(path, tangents, i)
const nextHandle = getFenceControlHandle(path, tangents, i + 1)
// Bézier controls: leave p1 along its OUT handle, arrive at p2 along its
// IN handle (= negated OUT handle).
const c1: Point2D = { x: p1.x + outHandle.x, y: p1.y + outHandle.y }
const c2: Point2D = { x: p2.x - nextHandle.x, y: p2.y - nextHandle.y }
for (let s = 1; s <= steps; s += 1) {
result.push(cubicBezier(p1, c1, c2, p2, s / steps))
}
}
return result
}
function frameFromPolyline(points: Point2D[], t: number): CurveFrame {
if (points.length === 0) {
return {
point: { x: 0, y: 0 },
tangent: { x: 1, y: 0 },
normal: { x: 0, y: 1 },
}
}
if (points.length === 1) {
return {
point: points[0]!,
tangent: { x: 1, y: 0 },
normal: { x: 0, y: 1 },
}
}
const clamped = clamp01(t)
const lastIndex = points.length - 1
const scaled = clamped * lastIndex
const lower = Math.min(lastIndex - 1, Math.floor(scaled))
const upper = lower + 1
const localU = scaled - lower
const a = points[lower]!
const b = points[upper]!
const point = {
x: a.x + (b.x - a.x) * localU,
y: a.y + (b.y - a.y) * localU,
}
const dx = b.x - a.x
const dy = b.y - a.y
const len = Math.hypot(dx, dy)
const tangent = len < EPSILON ? { x: 1, y: 0 } : { x: dx / len, y: dy / len }
return {
point,
tangent,
normal: { x: -tangent.y, y: tangent.x },
}
}
/**
* Frame (point + tangent + normal) at parameter `t` in [0, 1] along the spline
* centerline. Same return shape as `getWallCurveFrameAt` so it is a drop-in for
* the arc branch. `t` is uniform over the sampled polyline (arc length is not
* reparameterised — adequate for marching posts / rails and far cheaper).
*/
export function getFenceSplineFrameAt(
path: ReadonlyArray<readonly [number, number]>,
t: number,
tangents?: TangentList,
segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN,
): CurveFrame {
return frameFromPolyline(sampleFenceSpline(path, tangents, segmentsPerSpan), t)
}
/** Total polyline length of the sampled spline centerline. */
export function getFenceSplineLength(
path: ReadonlyArray<readonly [number, number]>,
tangents?: TangentList,
segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN,
): number {
const points = sampleFenceSpline(path, tangents, segmentsPerSpan)
let total = 0
for (let i = 1; i < points.length; i += 1) {
total += distance(points[i - 1]!, points[i]!)
}
return total
}
@@ -149,6 +149,19 @@ function straightStairAABB(
const ARC_SAMPLES = 48
function getSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
if ((stair.topLandingMode ?? 'none') !== 'integrated') return 0
const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9)
const width = Math.max(stair.width ?? 1, 0.4)
const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8))
return (
Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) *
Math.sign(sweepAngle || 1)
)
}
/** Bounding box of a curved / spiral stair's annular sector (plus the
* integrated spiral top landing when present). */
function arcStairAABB(stair: StairNode): StairFootprintAABB | null {
@@ -158,7 +171,8 @@ function arcStairAABB(stair: StairNode): StairFootprintAABB | null {
const width = Math.max(stair.width ?? 1, 0.4)
const outerRadius = innerRadius + width
let sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
const rawSweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
let sweep = rawSweep
// A full revolution would make the arc degenerate; clamp just under 2π the
// same way the floor-plan emitter does so the sampled box stays correct.
if (Math.abs(sweep) >= Math.PI * 2) sweep = Math.sign(sweep || 1) * (Math.PI * 2 - 0.001)
@@ -175,17 +189,17 @@ function arcStairAABB(stair: StairNode): StairFootprintAABB | null {
extendByLocal(box, stair, cos * outerRadius, sin * outerRadius)
}
// Integrated spiral top landing — a rectangle hung off the outer rim.
// Integrated spiral top landing renders as an angular extension of the
// annular stair body, not as a rectangular box outside the outer rim.
if (isSpiral && stair.topLandingMode === 'integrated') {
const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1)
const halfWidth = width / 2
for (const [cornerX, cornerZ] of [
[outerRadius, -halfWidth],
[outerRadius + depth, -halfWidth],
[outerRadius + depth, halfWidth],
[outerRadius, halfWidth],
] as const) {
extendByLocal(box, stair, cornerX, cornerZ)
const landingSweep = getSpiralLandingSweep(stair, rawSweep)
const landingSteps = Math.max(1, Math.ceil(Math.abs(landingSweep) / (Math.PI / 24)))
for (let step = 0; step <= landingSteps; step += 1) {
const angle = rawSweep / 2 + (landingSweep * step) / landingSteps
const cos = Math.cos(angle)
const sin = Math.sin(angle)
extendByLocal(box, stair, cos * innerRadius, sin * innerRadius)
extendByLocal(box, stair, cos * outerRadius, sin * outerRadius)
}
}
@@ -508,4 +508,48 @@ describe('syncAutoStairOpenings', () => {
expect(landingUpdate?.data.holes).toEqual([manualOpening])
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'manual' }])
})
test('does not add a separate rectangular hole for an integrated spiral top landing', () => {
const building = BuildingNode.parse({ name: 'Building' })
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
const landingSlab = SlabNode.parse({
name: 'Landing Slab',
parentId: upper.id,
polygon: [
[-4, -4],
[4, -4],
[4, 4],
[-4, 4],
],
})
const stair = StairNode.parse({
id: 'stair_spiral_landing',
name: 'Spiral Landing Stair',
parentId: ground.id,
position: [0, 0, 0],
rotation: Math.PI / 2,
stairType: 'spiral',
fromLevelId: ground.id,
toLevelId: upper.id,
slabOpeningMode: 'destination',
innerRadius: 0.35,
width: 1.2,
sweepAngle: Math.PI * 1.6,
topLandingMode: 'integrated',
topLandingDepth: 1.1,
})
const nodes = Object.fromEntries(
[building, ground, upper, landingSlab, stair].map((node) => [node.id, node]),
) as Record<string, AnyNode>
const updates = syncAutoStairOpenings(nodes)
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
const holes = landingUpdate?.data.holes ?? []
const rectangularHoles = holes.filter((hole) => hole.length === 4)
expect(holes).toHaveLength(1)
expect(rectangularHoles).toHaveLength(0)
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
})
})
@@ -454,22 +454,6 @@ function getSpiralOpeningPolygon(stair: StairNode, offset: number = 0): Point2D[
})
}
function getSpiralLandingPolygon(stair: StairNode, offset: number = 0): Point2D[] {
const width = Math.max(stair.width ?? 1, 0.4)
const outerRadius = Math.max(0.05, (stair.innerRadius ?? 0.9) + width)
const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1)
const halfWidth = width / 2
const localPoints: Point2D[] = [
[outerRadius - offset, -halfWidth - offset],
[outerRadius + depth + offset, -halfWidth - offset],
[outerRadius + depth + offset, halfWidth + offset],
[outerRadius - offset, halfWidth + offset],
]
return localPoints.map(([x, z]) => toWorldPlanPoint(stair, x, z))
}
function getStraightOpeningPolygonsForSurface(
stair: StairNode,
nodes: Record<string, AnyNode>,
@@ -575,11 +559,7 @@ function getStairOpeningPolygons(
if (stair.stairType === 'spiral') {
const offset = Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)
const polygons = [getSpiralOpeningPolygon(stair, offset)]
if (stair.topLandingMode === 'integrated') {
polygons.push(getSpiralLandingPolygon(stair, offset))
}
return polygons
return [getSpiralOpeningPolygon(stair, offset)]
}
if (typeof targetElevation === 'number') {
+1 -1
View File
@@ -155,7 +155,7 @@ export function cloneLevelSubtree(
levelId: AnyNodeId,
): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map<string, string> } {
const levelNode = nodes[levelId]
if (!levelNode || levelNode.type !== 'level') {
if (levelNode?.type !== 'level') {
throw new Error(`Node "${levelId}" is not a level`)
}