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