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
+2 -2
View File
@@ -25,10 +25,10 @@ export function buildBoxVentFloorplan(
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)
+15 -1
View File
@@ -19,6 +19,7 @@ import {
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildBoxVentGeometry } from './geometry'
const defaultMaterial = new THREE.MeshStandardMaterial({
@@ -134,6 +135,19 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat)
}, [surfaceQuat, node.rotation, yAxis])
// Map vent-local geometry into the host segment's local frame (the frame the
// trim cut prisms live in) — same pose the inner mesh group is mounted with.
const localToSegment = useMemo(
() =>
new THREE.Matrix4().compose(
new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0),
composedQuat,
new THREE.Vector3(1, 1, 1),
),
[node.position[0], node.position[1], node.position[2], composedQuat],
)
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
if (!segment) return null
// `node.position` is segment-local (the placement + move tools resolve
@@ -158,7 +172,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
>
<mesh
castShadow
geometry={geometry}
geometry={clippedGeometry ?? geometry}
material={material}
name="box-vent-surface"
receiveShadow
+2 -2
View File
@@ -28,10 +28,10 @@ export function buildChimneyFloorplan(
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
// Compose roof → segment → chimney in plan coords. Each rotation is
// negated so SVG's y-down CW matches Three.js' top-down CCW.
+1 -1
View File
@@ -202,7 +202,7 @@ export default function ChimneyPanel() {
const state = useScene.getState()
const worldPt = new Vector3(wx, 0, wz)
for (const candidate of Object.values(state.nodes)) {
if (!candidate || candidate.type !== 'roof-segment') continue
if (candidate?.type !== 'roof-segment') continue
const seg = candidate as RoofSegmentNode
const segObj = sceneRegistry.nodes.get(seg.id)
if (!segObj) continue
+35 -5
View File
@@ -19,6 +19,7 @@ import {
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildChimneyGeometry } from './geometry'
import { carveChimneyHoles } from './holes'
import { trimChimneyBodyAgainstRoof } from './roof-trim'
@@ -124,6 +125,35 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
[geo, trimmedBody],
)
// Map chimney-local geometry into the host segment's local frame (where the
// trim cut prisms live) — same pose the inner mesh group is mounted with
// (node.position x/z, y=0 — chimneys anchor to the surface, not position[1]).
// Every chimney part (body, cap, flues, cricket, bands) shares this pose, so
// each is clipped by the segment trim independently. The body chains AFTER
// the through-roof self-trim, so both CSG passes compose.
const localToSegment = useMemo(
() =>
new THREE.Matrix4().compose(
new THREE.Vector3(node.position[0] ?? 0, 0, node.position[2] ?? 0),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), node.rotation ?? 0),
new THREE.Vector3(1, 1, 1),
),
[node.position[0], node.position[2], node.rotation],
)
const clippedBody = useSegmentTrimClippedGeometry(
trimmedBody ?? geo?.body ?? null,
segment,
localToSegment,
)
const clippedCap = useSegmentTrimClippedGeometry(geo?.cap ?? null, segment, localToSegment)
const clippedFlues = useSegmentTrimClippedGeometry(geo?.flues ?? null, segment, localToSegment)
const clippedCricket = useSegmentTrimClippedGeometry(
geo?.cricket ?? null,
segment,
localToSegment,
)
const clippedBands = useSegmentTrimClippedGeometry(geo?.bands ?? null, segment, localToSegment)
// Per-instance fallback materials. Were previously module-scoped
// singletons shared across every chimney — a paint-mode or debug
// system that mutates `surfaceMaterial` would have flipped the look
@@ -238,7 +268,7 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
>
<mesh
castShadow
geometry={trimmedBody ?? geo.body}
geometry={clippedBody ?? trimmedBody ?? geo.body}
material={surfaceArray}
name="chimney-body"
receiveShadow
@@ -246,7 +276,7 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
{geo.cap && (
<mesh
castShadow
geometry={geo.cap}
geometry={clippedCap ?? geo.cap}
material={surfaceArray}
name="chimney-cap"
receiveShadow
@@ -255,7 +285,7 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
{geo.flues && (
<mesh
castShadow
geometry={geo.flues}
geometry={clippedFlues ?? geo.flues}
material={surfaceArray}
name="chimney-flues"
receiveShadow
@@ -264,7 +294,7 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
{geo.cricket && (
<mesh
castShadow
geometry={geo.cricket}
geometry={clippedCricket ?? geo.cricket}
material={surfaceMaterial}
name="chimney-cricket"
receiveShadow
@@ -273,7 +303,7 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
{geo.bands && (
<mesh
castShadow
geometry={geo.bands}
geometry={clippedBands ?? geo.bands}
material={surfaceMaterial}
name="chimney-bands"
receiveShadow
+29 -29
View File
@@ -6,10 +6,15 @@ import {
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
movingFootprintAnchors,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import {
applyFloorplanAlignment,
getFloorStackPreviewPosition,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX,
useEditor,
type WallPlanPoint,
@@ -17,25 +22,10 @@ import {
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
/**
* 2D floor-plan move handler for column — mirrors `itemFloorplanMoveTarget`:
* each pointermove writes the absolute world-plan position straight to
* `useScene` (history paused by the overlay). The 2D SVG and the 3D group
* transform both read `node.position` reactively, so they stay in lockstep;
* the overlay's snapshot-diff makes the drag one undoable step. `canCommit`
* only validates.
*
* Columns previously fell through to the overlay's generic free-translate
* path, which aligned a column by its bbox *centre* and gathered candidates
* from SVG bounding boxes only (missing wall faces / diagonal walls). Routing
* through a kind-specific target gives column the same footprint-edge
* alignment as shelf / item — including snapping flush to wall faces (the
* pillar↔wall case this whole feature targets).
*
* Earlier this used the `useLiveTransforms` + imperative-mesh pattern; for a
* `position`-field kind that leaves the 3D group stuck at the old spot on
* commit (nothing reconciles it off the cleared live transform, since the
* geometry doesn't rebuild on a position-only change). See the shelf handler
* for the full rationale.
* 2D floor-plan move handler for column. Columns need the same footprint-edge
* alignment as shelf / item, but they must preview through live transforms and
* commit once on release so the overlay does not churn the scene store on
* every pointermove.
*
* Column stores rotation as a scalar (not a tuple); position is `[x, y, z]`.
*/
@@ -56,14 +46,14 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
const session: FloorplanMoveTargetSession = {
affectedIds: [columnId],
apply({ planPoint, modifiers }) {
apply({ planPoint }) {
const snap = (value: number) => {
if (modifiers.shiftKey) return value
if (!isGridSnapActive()) return value
const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step
}
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
// Figma-style alignment layered on the grid snap (Alt bypasses alignment; Shift all snap).
// Figma-style alignment layered on the active snap mode.
const { point: snapped } = applyFloorplanAlignment(
gridSnapped,
movingFootprintAnchors(
@@ -73,26 +63,36 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
rotationY,
),
candidates,
{ bypass: modifiers.altKey || modifiers.shiftKey },
{ bypass: !isMagneticSnapActive() },
)
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
lastPosition = next
const snapKey = `${snapped[0]},${snapped[1]}`
if (!modifiers.shiftKey && snapKey !== lastSnapKey) {
if (snapKey !== lastSnapKey) {
triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey
}
// Single source of truth — write the absolute position straight to the
// scene (history paused by the overlay). 2D SVG and 3D group transform
// both follow `node.position` reactively, so they can't diverge.
useScene.getState().updateNodes([{ id: columnId, data: { position: next } }])
const visualPosition = getFloorStackPreviewPosition({
node,
position: next,
rotation: rotationY,
levelId: node.parentId ?? null,
})
sceneRegistry.nodes.get(columnId)?.position.set(...visualPosition)
useLiveTransforms.getState().set(columnId, {
position: next,
rotation: rotationY,
})
},
canCommit() {
const live = useScene.getState().nodes[columnId] as ColumnNode | undefined
if (!live || live.type !== 'column') return false
if (live?.type !== 'column') return false
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
},
commit() {
useScene.getState().updateNodes([{ id: columnId, data: { position: lastPosition } }])
},
}
return session
}
+2 -2
View File
@@ -19,10 +19,10 @@ export function buildCupolaFloorplan(
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)
+15 -1
View File
@@ -19,6 +19,7 @@ import {
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildCupolaGeometry } from './geometry'
const defaultMaterial = new THREE.MeshStandardMaterial({
@@ -81,6 +82,19 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => {
return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat)
}, [surfaceQuat, node.rotation, yAxis])
// Map cupola-local geometry into the host segment's local frame (where the
// trim cut prisms live) — same pose the inner mesh group is mounted with.
const localToSegment = useMemo(
() =>
new THREE.Matrix4().compose(
new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0),
composedQuat,
new THREE.Vector3(1, 1, 1),
),
[node.position[0], node.position[1], node.position[2], composedQuat],
)
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
if (!segment) return null
const segPos = segment.position ?? [0, 0, 0]
@@ -96,7 +110,7 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => {
>
<mesh
castShadow
geometry={geometry}
geometry={clippedGeometry ?? geometry}
material={material}
name="cupola-surface"
receiveShadow
+1 -1
View File
@@ -43,7 +43,7 @@ import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dim
*/
export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null {
const wall = ctx.parent as WallNode | null
if (!wall || wall.type !== 'wall') return null
if (wall?.type !== 'wall') return null
const [x1, z1] = wall.start
const [x2, z2] = wall.end
+18
View File
@@ -304,6 +304,24 @@ export function generateDormerGeometry(
roofType: dormer.roofType,
width: vsWidth,
depth: vsDepth,
trim: {
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,
},
wallHeight: Math.max(0.05, dormer.height) + skirt,
// The dormer schema still expresses its roof as a height; translate
// to the pitch the segment math now expects so the virtual segment
+2 -2
View File
@@ -33,10 +33,10 @@ export function buildDormerFloorplan(
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
// Compose roof → segment → dormer in plan coords. Each rotation negated
// so SVG's y-down CW matches Three.js' top-down CCW.
@@ -97,7 +97,7 @@ export function DormerPositionSection({
const state = useScene.getState()
const worldPt = new Vector3(wx, 0, wz)
for (const candidate of Object.values(state.nodes)) {
if (!candidate || candidate.type !== 'roof-segment') continue
if (candidate?.type !== 'roof-segment') continue
const seg = candidate as RoofSegmentNode
const segObj = sceneRegistry.nodes.get(seg.id)
if (!segObj) continue
+23 -2
View File
@@ -18,7 +18,8 @@ import {
useViewer,
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three'
import * as THREE from 'three'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import {
buildDormerFallbackGeometry,
DORMER_GABLE_MATERIAL_INDEX,
@@ -145,6 +146,19 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
useEffect(() => () => geometry?.dispose(), [geometry])
// Map dormer-local geometry into the host segment's local frame (where the
// trim cut prisms live) — same pose the inner mesh group is mounted with.
const localToSegment = useMemo(
() =>
new THREE.Matrix4().compose(
new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), node.rotation ?? 0),
new THREE.Vector3(1, 1, 1),
),
[node.position[0], node.position[1], node.position[2], node.rotation],
)
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
if (!(segment && geometry)) return null
// Dormers are mounted inside `RoofRenderer`'s `roof-elements` group
@@ -171,8 +185,15 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
ref={ref}
rotation-y={node.rotation ?? 0}
>
<mesh castShadow geometry={geometry} material={material} name="dormer-body" receiveShadow />
<mesh
castShadow
geometry={clippedGeometry ?? geometry}
material={material}
name="dormer-body"
receiveShadow
/>
<DormerWindowAssembly
dormerToSegment={localToSegment}
frameMaterial={frameSideMat}
glassMaterial={glassMat}
node={node}
+58 -33
View File
@@ -3,6 +3,7 @@
import type { DormerNode, RoofSegmentNode } from '@pascal-app/core'
import { useEffect, useMemo } from 'react'
import * as THREE from 'three'
import { TrimClippedMesh } from '../shared/use-segment-trim-clip'
import { getDormerExposedFaces, getDormerSkirtWindowDims } from './csg-geometry'
import { buildDormerWindowGeometries, type DormerWindowShape } from './window-frame'
@@ -22,11 +23,16 @@ const DormerWindowAssembly = ({
segment,
frameMaterial,
glassMaterial,
dormerToSegment,
}: {
node: DormerNode
segment: RoofSegmentNode
frameMaterial: THREE.Material
glassMaterial: THREE.Material
// Maps dormer-mesh-local space into the host segment-local frame (where the
// trim cut prisms live). Threaded from the renderer so the window glass /
// frame / sill slice at the trim plane like the dormer body.
dormerToSegment: THREE.Matrix4
}) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const skirtWin = useMemo(
@@ -140,39 +146,58 @@ const DormerWindowAssembly = ({
// FrontSide points outward (-Z in segment frame). With the rotation,
// the sill always extrudes along the group's local +Z, so its position
// no longer needs to flip per-face.
const renderFace = (zPos: number, yRot: number, keyPrefix: string) => (
<group name={`dormer-window-${keyPrefix}`} position={[winX, winY, zPos]} rotation-y={yRot}>
{winGeo.glassPanes.map((pane, i) => (
<mesh
geometry={pane.geo}
key={`${keyPrefix}-glass-${i}`}
material={glassMaterial}
name={`dormer-glass-${keyPrefix}-${i}`}
position={pane.pos}
/>
))}
{winGeo.frameBars.map((bar, i) => (
<mesh
castShadow
geometry={bar.geo}
key={`${keyPrefix}-bar-${i}`}
material={frameMaterial}
name={`dormer-frame-${keyPrefix}-${i}`}
position={bar.pos}
/>
))}
{sillGeo && (
<mesh
castShadow
geometry={sillGeo}
material={frameMaterial}
name={`dormer-sill-${keyPrefix}`}
position={[0, -winH / 2 - sillT / 2, sillD / 2]}
receiveShadow
/>
)}
</group>
)
const renderFace = (zPos: number, yRot: number, keyPrefix: string) => {
// Compose this face group's transform onto the dormer→segment matrix, so
// each window part can be clipped by the trim in segment-local space.
const faceToSegment = new THREE.Matrix4()
.copy(dormerToSegment)
.multiply(
new THREE.Matrix4().compose(
new THREE.Vector3(winX, winY, zPos),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yRot),
new THREE.Vector3(1, 1, 1),
),
)
return (
<group name={`dormer-window-${keyPrefix}`} position={[winX, winY, zPos]} rotation-y={yRot}>
{winGeo.glassPanes.map((pane, i) => (
<TrimClippedMesh
geometry={pane.geo}
key={`${keyPrefix}-glass-${i}`}
material={glassMaterial}
name={`dormer-glass-${keyPrefix}-${i}`}
parentToSegment={faceToSegment}
position={pane.pos}
segment={segment}
/>
))}
{winGeo.frameBars.map((bar, i) => (
<TrimClippedMesh
castShadow
geometry={bar.geo}
key={`${keyPrefix}-bar-${i}`}
material={frameMaterial}
name={`dormer-frame-${keyPrefix}-${i}`}
parentToSegment={faceToSegment}
position={bar.pos}
segment={segment}
/>
))}
{sillGeo && (
<TrimClippedMesh
castShadow
geometry={sillGeo}
material={frameMaterial}
name={`dormer-sill-${keyPrefix}`}
parentToSegment={faceToSegment}
position={[0, -winH / 2 - sillT / 2, sillD / 2]}
receiveShadow
segment={segment}
/>
)}
</group>
)
}
return (
<>
@@ -41,7 +41,7 @@ export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) {
: undefined,
)
if (!gutter || gutter.type !== 'gutter') return null
if (gutter?.type !== 'gutter') return null
const storeOutlets = gutter.outlets ?? []
const effectiveOutlets = (override?.outlets as GutterOutlet[] | undefined) ?? storeOutlets
const outlet = effectiveOutlets.find((o) => o.id === node.outletId)
+27 -1
View File
@@ -21,6 +21,7 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { computeEaveY } from '../gutter/eave-snap'
import { resolveGutterOutletById } from '../gutter/outlet-lookup'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildDownspoutGeometry } from './geometry'
import { computeDownspoutRouting } from './routing'
@@ -143,6 +144,31 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => {
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
// Map downspout-local geometry into the host segment's local frame (where the
// trim cut prisms live). Recompose the same outlet pose the inner mesh group
// is mounted with (gutter offset + rotation → outlet → eave Y). Computed
// before the early returns so the hook order stays stable.
const localToSegment = useMemo(() => {
if (!effectiveGutter || !effectiveSegment) return new THREE.Matrix4()
const outlet = resolveGutterOutletById(effectiveGutter, node.outletId)
if (!outlet) return new THREE.Matrix4()
const liveEaveY = computeEaveY(effectiveSegment)
const gutterRotY = effectiveGutter.rotation ?? 0
const gutterX = effectiveGutter.position[0] ?? 0
const gutterZ = effectiveGutter.position[2] ?? 0
const cos = Math.cos(gutterRotY)
const sin = Math.sin(gutterRotY)
const outletSegX = gutterX + (outlet.x * cos + outlet.z * sin)
const outletSegZ = gutterZ + (-outlet.x * sin + outlet.z * cos)
const outletSegY = liveEaveY + outlet.y
return new THREE.Matrix4().compose(
new THREE.Vector3(outletSegX, outletSegY, outletSegZ),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), gutterRotY),
new THREE.Vector3(1, 1, 1),
)
}, [effectiveGutter, effectiveSegment, node.outletId])
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment)
if (!effectiveGutter || !effectiveSegment) return null
const outlet = resolveGutterOutletById(effectiveGutter, node.outletId)
if (!outlet) return null
@@ -177,7 +203,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => {
>
<mesh
castShadow
geometry={geometry}
geometry={clippedGeometry ?? geometry}
material={material}
name="downspout-surface"
receiveShadow
+1 -1
View File
@@ -80,7 +80,7 @@ export function resolveDownspoutRouting(
): DownspoutRouting | null {
if (!node.gutterId) return null
const gutter = sceneApi.get<GutterNode>(node.gutterId as AnyNodeId)
if (!gutter || gutter.type !== 'gutter') return null
if (gutter?.type !== 'gutter') return null
const segment = gutter.roofSegmentId
? sceneApi.get<RoofSegmentNode>(gutter.roofSegmentId as AnyNodeId)
: undefined
+1 -1
View File
@@ -225,7 +225,7 @@ const DuctTerminalTool = () => {
const nodes = useScene.getState().nodes
let best: { hit: Vector3; height: number } | null = null
for (const node of Object.values(nodes)) {
if (!node || node.type !== 'ceiling') continue
if (node?.type !== 'ceiling') continue
if (resolveLevelId(node, nodes) !== activeLevelId) continue
const ceiling = node as {
height?: number
+2 -2
View File
@@ -19,10 +19,10 @@ export function buildEyebrowVentFloorplan(
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)
+15 -1
View File
@@ -19,6 +19,7 @@ import {
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildEyebrowVentGeometry } from './geometry'
const defaultMaterial = new THREE.MeshStandardMaterial({
@@ -83,6 +84,19 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) =>
return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat)
}, [surfaceQuat, node.rotation, yAxis])
// Map vent-local geometry into the host segment's local frame (where the trim
// cut prisms live) — same pose the inner mesh group is mounted with.
const localToSegment = useMemo(
() =>
new THREE.Matrix4().compose(
new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0),
composedQuat,
new THREE.Vector3(1, 1, 1),
),
[node.position[0], node.position[1], node.position[2], composedQuat],
)
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
if (!segment) return null
const segPos = segment.position ?? [0, 0, 0]
@@ -98,7 +112,7 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) =>
>
<mesh
castShadow
geometry={geometry}
geometry={clippedGeometry ?? geometry}
material={material}
name="eyebrow-vent-surface"
receiveShadow
@@ -97,7 +97,7 @@ function snapshotLinked(
const { nodes } = useScene.getState()
const out: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!node || node.type !== 'fence') continue
if (node?.type !== 'fence') continue
if (node.id === fenceId) continue
if ((node.parentId ?? null) !== parentId) continue
if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) continue
+82 -10
View File
@@ -1,6 +1,17 @@
import type { FenceNode as FenceNodeType, HandleDescriptor, NodeDefinition } from '@pascal-app/core'
import {
type FenceNode as FenceNodeType,
getFenceControlHandle,
type HandleDescriptor,
isSplineFence,
type NodeDefinition,
} from '@pascal-app/core'
import { buildFenceFloorplan } from './floorplan'
import { fenceCurveAffordance, fenceMoveEndpointAffordance } from './floorplan-affordances'
import {
fenceControlPointAffordance,
fenceCurveAffordance,
fenceMoveEndpointAffordance,
fenceTangentAffordance,
} from './floorplan-affordances'
import { fenceFloorplanMoveTarget } from './floorplan-move'
import { buildFenceGeometry } from './geometry'
import { fencePaint } from './paint'
@@ -57,7 +68,9 @@ function fenceSideMoveHandle(side: 'front' | 'back'): HandleDescriptor<FenceNode
return Math.atan2(-sign * normalZ, sign * normalX)
},
},
cursor: 'move',
// The move itself is constrained by `MoveFenceTool`; keep the visual as a
// one-direction chevron instead of the generic four-way move cross.
cursor: 'ew-resize',
}
}
@@ -111,13 +124,68 @@ function fenceCornerPicker(endpoint: 'start' | 'end'): HandleDescriptor<FenceNod
}
}
const fenceHandles: HandleDescriptor<FenceNodeType>[] = [
fenceSideMoveHandle('front'),
fenceSideMoveHandle('back'),
fenceHeightHandle(),
fenceCornerPicker('start'),
fenceCornerPicker('end'),
]
const TANGENT_HANDLE_ARM_SCALE = 3
function fenceControlPointPicker(index: number): HandleDescriptor<FenceNodeType> {
return {
kind: 'tap-action',
shape: 'corner-picker',
cursor: 'move',
nodeHeight: (n) => n.height ?? 1.8,
onActivate: (node, _scene, editor) => editor.engageControlPointMove(node, index),
placement: {
position: (n) => {
const point = n.path?.[index] ?? n.start
return [point[0], 0, point[1]]
},
},
}
}
function fenceTangentPicker(index: number, side: 'in' | 'out'): HandleDescriptor<FenceNodeType> {
const sign = side === 'out' ? 1 : -1
return {
kind: 'tap-action',
shape: 'corner-picker',
round: true,
cursor: 'move',
nodeHeight: (n) => (n.height ?? 1.8) * 0.6,
onActivate: (node, _scene, editor) => editor.engageTangentMove(node, index, side),
placement: {
position: (n) => {
const point = n.path?.[index] ?? n.start
if (!n.path) return [point[0], 0, point[1]]
const handle = getFenceControlHandle(n.path, n.tangents, index)
return [
point[0] + sign * handle.x * TANGENT_HANDLE_ARM_SCALE,
0,
point[1] + sign * handle.y * TANGENT_HANDLE_ARM_SCALE,
]
},
},
}
}
const fenceHandles = (node: FenceNodeType): HandleDescriptor<FenceNodeType>[] => {
if (isSplineFence(node) && node.path) {
return [
fenceHeightHandle(),
...node.path.flatMap((_, index) => [
fenceControlPointPicker(index),
fenceTangentPicker(index, 'out'),
fenceTangentPicker(index, 'in'),
]),
]
}
return [
fenceSideMoveHandle('front'),
fenceSideMoveHandle('back'),
fenceHeightHandle(),
fenceCornerPicker('start'),
fenceCornerPicker('end'),
]
}
/**
* Fence — Phase 5 batch kind. Stage B complete: `def.geometry` drives
@@ -209,6 +277,8 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
// pointer-up).
floorplanAffordances: {
'move-endpoint': fenceMoveEndpointAffordance,
'move-control-point': fenceControlPointAffordance,
'move-tangent': fenceTangentAffordance,
curve: fenceCurveAffordance,
},
// Body move on the fence is driven by the two `move-arrow` chevrons
@@ -225,6 +295,8 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
affordanceTools: {
curve: () => import('./curve-tool'),
'move-endpoint': () => import('./move-endpoint-tool'),
'move-control-point': () => import('./move-control-point-tool'),
'move-tangent': () => import('./move-tangent-tool'),
move: () => import('./move-tool'),
},
@@ -16,6 +16,7 @@ import {
type FencePlanPoint,
getSegmentGridStep,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
isSegmentLongEnough,
snapBuildingLocalToWorldGrid,
@@ -43,6 +44,13 @@ import {
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
type FenceEndpointPayload = { fenceId: AnyNodeId; endpoint: 'start' | 'end' }
type FenceControlPointPayload = { fenceId: AnyNodeId; index: number }
type FenceTangentPayload = { fenceId: AnyNodeId; index: number; side: 'in' | 'out' }
// Must match the floorplan builder's TANGENT_HANDLE_ARM_SCALE: the on-screen
// arm is this many times the raw tangent vector, so dividing the dragged
// offset back out recovers the stored tangent.
const TANGENT_HANDLE_ARM_SCALE = 3
function pointsNearlyEqual(a: FencePlanPoint, b: FencePlanPoint): boolean {
return (
@@ -88,7 +96,7 @@ function collectLinkedFences(
/**
* Fence curve sagitta drag — 1:1 mirror of `wallCurveAffordance`. Drag
* projects the pointer onto the chord normal to compute `curveOffset`,
* snaps to grid (Shift bypasses), clamps to `getMaxWallCurveOffset`,
* snaps to grid when that mode is active, clamps to `getMaxWallCurveOffset`,
* normalizes via `normalizeWallCurveOffset`. Same single-undo dance — the
* dispatcher handles snapshot / pause / resume around `apply`. Lives in
* the same file as the endpoint affordance to keep the two fence
@@ -105,17 +113,16 @@ export const fenceCurveAffordance: FloorplanAffordance<FenceNode> = {
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapStep = getSegmentGridStep()
const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep)
const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep)
const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0
const x = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0]
const y = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1]
const offsetFromMidpoint = -(
(x - chord.midpoint.x) * chord.normal.x +
(y - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = modifiers.shiftKey
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const snappedOffset =
snapStep > 0 ? snapScalarToGrid(offsetFromMidpoint, snapStep) : offsetFromMidpoint
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)),
@@ -136,6 +143,114 @@ export const fenceCurveAffordance: FloorplanAffordance<FenceNode> = {
},
}
/**
* Spline control-point drag — reshapes one point of the fence `path`. Grid
* snap follows the active mode; start/end stay pinned to the path ends so endpoint-
* dependent code stays valid. Publishes a live override per tick, commits the
* final path as one tracked change. No linked-fence cascade: a spline's shape
* is self-contained.
*/
export const fenceControlPointAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload }): FloorplanAffordanceSession {
const { index } = payload as FenceControlPointPayload
const fenceId = node.id as AnyNodeId
const originalPath: FencePlanPoint[] = (node.path ?? []).map((p) => [p[0], p[1]])
let lastPath = originalPath
const buildPatch = (point: FencePlanPoint): Record<string, unknown> => {
const nextPath = originalPath.map((p, i): FencePlanPoint => (i === index ? point : p))
lastPath = nextPath
const patch: Record<string, unknown> = { path: nextPath }
if (index === 0) patch.start = point
if (index === nextPath.length - 1) patch.end = point
return patch
}
return {
affectedIds: [fenceId],
apply({ planPoint, modifiers }) {
const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0
const x = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0]
const y = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1]
useLiveNodeOverrides.getState().set(fenceId, buildPatch([x, y]))
useScene.getState().markDirty(fenceId)
},
canCommit() {
return lastPath.length >= 2
},
commit() {
const data: Partial<FenceNode> = { path: lastPath }
data.start = lastPath[0]
data.end = lastPath[lastPath.length - 1]
useScene.getState().updateNodes([{ id: fenceId, data }])
useLiveNodeOverrides.getState().clear(fenceId)
},
}
},
}
/**
* Spline tangent-handle drag — bends the curve through one control point. The
* dragged end (in / out) gives the OUT-handle vector (negated for the IN end);
* the IN handle is always the mirror so the curve stays smooth (symmetric).
* The visual arm is `TANGENT_HANDLE_ARM_SCALE`× the stored vector, so we divide
* that factor out before storing. Writes `tangents[index]`, padding the array
* to the path length with nulls so untouched points keep their auto tangent.
*/
export const fenceTangentAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload }): FloorplanAffordanceSession {
const { index, side } = payload as FenceTangentPayload
const fenceId = node.id as AnyNodeId
const path = node.path ?? []
const anchor = path[index] ?? node.start
let lastTangents: Array<[number, number] | null> = (node.tangents ?? []).map((t) =>
t ? [t[0], t[1]] : null,
)
const buildTangents = (vec: [number, number]): Array<[number, number] | null> => {
const next: Array<[number, number] | null> = Array.from(
{ length: path.length },
(_, i) => lastTangents[i] ?? null,
)
next[index] = vec
lastTangents = next
return next
}
return {
affectedIds: [fenceId],
apply({ planPoint, modifiers }) {
const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0
const px = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0]
const py = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1]
// Arm vector from the anchor to the dragged handle, in plan meters.
let armX = px - anchor[0]
let armY = py - anchor[1]
// The IN end is the mirror, so its drag describes the negated OUT vector.
if (side === 'in') {
armX = -armX
armY = -armY
}
const vec: [number, number] = [
armX / TANGENT_HANDLE_ARM_SCALE,
armY / TANGENT_HANDLE_ARM_SCALE,
]
useLiveNodeOverrides.getState().set(fenceId, { tangents: buildTangents(vec) })
useScene.getState().markDirty(fenceId)
},
canCommit() {
return true
},
commit() {
useScene
.getState()
.updateNodes([{ id: fenceId, data: { tangents: lastTangents } as Partial<FenceNode> }])
useLiveNodeOverrides.getState().clear(fenceId)
},
}
},
}
export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
start({ node, payload, nodes }): FloorplanAffordanceSession {
const { endpoint } = payload as FenceEndpointPayload
+58 -23
View File
@@ -6,7 +6,12 @@ import {
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { getSegmentGridStep, isSegmentLongEnough, snapPointToGrid } from '@pascal-app/editor'
import {
getSegmentGridStep,
isGridSnapActive,
isSegmentLongEnough,
snapPointToGrid,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
type PlanPoint = [number, number]
@@ -15,7 +20,7 @@ function pointsEqual(a: PlanPoint, b: PlanPoint): boolean {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedFenceSnapshot = { id: AnyNodeId; start: PlanPoint; end: PlanPoint }
type LinkedFenceSnapshot = { id: AnyNodeId; start: PlanPoint; end: PlanPoint; path?: PlanPoint[] }
function getLinkedFenceSnapshots(args: {
fenceId: AnyNodeId
@@ -40,12 +45,21 @@ function getLinkedFenceSnapshots(args: {
id: fence.id as AnyNodeId,
start: [fence.start[0], fence.start[1]],
end: [fence.end[0], fence.end[1]],
path: fence.path?.map((point) => [point[0], point[1]]),
})
}
}
return snapshots
}
function translatePath(
path: PlanPoint[] | undefined,
dx: number,
dz: number,
): PlanPoint[] | undefined {
return path?.map((point) => [point[0] + dx, point[1] + dz])
}
/**
* 2D floor-plan body move for fence. Mirrors `wallFloorplanMoveTarget`
* but without bridge-wall planning: fence corners cascade through
@@ -76,23 +90,32 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
let lastDelta: PlanPoint = [0, 0]
let lastNextStart: PlanPoint = originalStart
let lastNextEnd: PlanPoint = originalEnd
let lastNextPath: PlanPoint[] | undefined = node.path?.map((point) => [point[0], point[1]])
const projectLinked = (
snapshot: LinkedFenceSnapshot,
nextStart: PlanPoint,
nextEnd: PlanPoint,
): { start: PlanPoint; end: PlanPoint } => ({
start: pointsEqual(snapshot.start, originalStart)
dx: number,
dz: number,
): { start: PlanPoint; end: PlanPoint; path?: PlanPoint[] } => {
const start = pointsEqual(snapshot.start, originalStart)
? nextStart
: pointsEqual(snapshot.start, originalEnd)
? nextEnd
: snapshot.start,
end: pointsEqual(snapshot.end, originalStart)
: snapshot.start
const end = pointsEqual(snapshot.end, originalStart)
? nextStart
: pointsEqual(snapshot.end, originalEnd)
? nextEnd
: snapshot.end,
})
: snapshot.end
return {
start,
end,
path: translatePath(snapshot.path, dx, dz),
}
}
const session: FloorplanMoveTargetSession = {
affectedIds: [fenceId, ...linkedOriginals.map((l) => l.id)],
@@ -104,10 +127,8 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
}
const rawDx = planPoint[0] - rawAnchor[0]
const rawDz = planPoint[1] - rawAnchor[1]
const step = getSegmentGridStep()
const nextStart: PlanPoint = modifiers.shiftKey
? [originalStart[0] + rawDx, originalStart[1] + rawDz]
: snapPointToGrid([originalStart[0] + rawDx, originalStart[1] + rawDz], step)
const step = isGridSnapActive() ? getSegmentGridStep() : 0
const nextStart = snapPointToGrid([originalStart[0] + rawDx, originalStart[1] + rawDz], step)
const dx = nextStart[0] - originalStart[0]
const dz = nextStart[1] - originalStart[1]
if (dx === lastDelta[0] && dz === lastDelta[1]) return
@@ -115,17 +136,29 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
const nextEnd: PlanPoint = [originalEnd[0] + dx, originalEnd[1] + dz]
lastNextStart = nextStart
lastNextEnd = nextEnd
lastNextPath = translatePath(
node.path?.map((point) => [point[0], point[1]]),
dx,
dz,
)
const linkedUpdates = modifiers.altKey
? []
: linkedOriginals.map((l) => ({ id: l.id, ...projectLinked(l, nextStart, nextEnd) }))
: linkedOriginals.map((l) => ({
id: l.id,
...projectLinked(l, nextStart, nextEnd, dx, dz),
}))
useLiveNodeOverrides
.getState()
.setMany([
[fenceId, { start: nextStart, end: nextEnd }],
[fenceId, { start: nextStart, end: nextEnd, path: lastNextPath }],
...linkedUpdates.map(
(u) => [u.id, { start: u.start, end: u.end }] as [string, Record<string, unknown>],
(u) =>
[u.id, { start: u.start, end: u.end, path: u.path }] as [
string,
Record<string, unknown>,
],
),
])
const sceneState = useScene.getState()
@@ -152,20 +185,22 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget<FenceNode> = ({ node
data: {
start: lastNextStart,
end: lastNextEnd,
path: lastNextPath,
metadata: { ...originalMetadata, isNew: false },
} as Partial<FenceNode>,
}
: { id: fenceId, data: { start: lastNextStart, end: lastNextEnd } }
: { id: fenceId, data: { start: lastNextStart, end: lastNextEnd, path: lastNextPath } }
const linkedUpdates = linkedOriginals.map((l) => ({
id: l.id,
...projectLinked(l, lastNextStart, lastNextEnd),
...projectLinked(l, lastNextStart, lastNextEnd, lastDelta[0], lastDelta[1]),
}))
useScene
.getState()
.updateNodes([
fenceUpdate,
...linkedUpdates.map((u) => ({ id: u.id, data: { start: u.start, end: u.end } })),
])
useScene.getState().updateNodes([
fenceUpdate,
...linkedUpdates.map((u) => ({
id: u.id,
data: { start: u.start, end: u.end, path: u.path },
})),
])
const overrides = useLiveNodeOverrides.getState()
overrides.clear(fenceId)
for (const l of linkedOriginals) overrides.clear(l.id)
+150 -55
View File
@@ -1,11 +1,13 @@
import {
type FloorplanGeometry,
type GeometryContext,
getWallCurveFrameAt,
getWallCurveLength,
getFenceCenterlineFrameAt,
getFenceCenterlineLength,
getFenceControlHandle,
getWallMidpointHandlePoint,
isCurvedWall,
sampleWallCenterline,
isSplineFence,
sampleFenceCenterline,
} from '@pascal-app/core'
import type { FenceNode } from './schema'
@@ -32,14 +34,22 @@ import type { FenceNode } from './schema'
* in the legacy panel and is fence-specific, so it lives with the kind.
*/
// The tangent handle arm is drawn this many times longer than the raw curve
// handle vector so it's easy to grab on screen even at the default (small)
// tangent. The `move-tangent` affordance divides this factor back out so the
// stored tangent matches the visual arm length. Must stay in sync with the
// 3D tool's arm scale.
const TANGENT_HANDLE_ARM_SCALE = 3
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function getFloorplanFenceLength(fence: FenceNode): number {
return isCurvedWall(fence)
? getWallCurveLength(fence)
: Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1])
if (isSplineFence(fence) || isCurvedWall(fence)) {
return getFenceCenterlineLength(fence)
}
return Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1])
}
/**
@@ -217,14 +227,15 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
const isActive = isSelected || isHighlighted
const showInteractiveChrome = isActive || isHovered
// Centerline path — sampled for curved fences so the underlay /
// Centerline path — sampled for spline / arc fences so the underlay /
// accent / glow all trace the same shape.
const centerlinePoints = isCurvedWall(node)
? sampleWallCenterline(node, 24)
: [
{ x: node.start[0], y: node.start[1] },
{ x: node.end[0], y: node.end[1] },
]
const centerlinePoints =
isSplineFence(node) || isCurvedWall(node)
? sampleFenceCenterline(node, 24)
: [
{ x: node.start[0], y: node.start[1] },
{ x: node.end[0], y: node.end[1] },
]
const pathD = buildCenterlinePathD(centerlinePoints)
// Stroke shifts: selected wins; hover (not selected) → `wallHoverStroke`
@@ -259,7 +270,7 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
// still sees end posts (matches legacy).
const markerTs = getFloorplanFenceMarkerTs(node)
const markerFrames = markerTs.map((t) => {
const frame = getWallCurveFrameAt(node, t)
const frame = getFenceCenterlineFrameAt(node, t)
return {
point: frame.point,
angle: Math.atan2(frame.tangent.y, frame.tangent.x),
@@ -329,36 +340,113 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
)
}
// 5. Hit-line for click detection.
children.push({
kind: 'hit-line',
x1: node.start[0],
y1: node.start[1],
x2: node.end[0],
y2: node.end[1],
strokeWidthPx: 18,
cursor: 'pointer',
})
// 5. Hit-line(s) for click detection. A straight/arc fence uses a single
// chord-spanning line; a spline fence emits one short hit-line per sampled
// span so the clickable region follows the curve instead of cutting the
// chord (there's no curved `hit-path` primitive yet).
if (isSplineFence(node)) {
for (let i = 1; i < centerlinePoints.length; i += 1) {
const a = centerlinePoints[i - 1]!
const b = centerlinePoints[i]!
children.push({
kind: 'hit-line',
x1: a.x,
y1: a.y,
x2: b.x,
y2: b.y,
strokeWidthPx: 18,
cursor: 'pointer',
})
}
} else {
children.push({
kind: 'hit-line',
x1: node.start[0],
y1: node.start[1],
x2: node.end[0],
y2: node.end[1],
strokeWidthPx: 18,
cursor: 'pointer',
})
}
// 6. Endpoint handles + side move-arrows + curve handle + length when
// selected. Mirrors the wall builder so fences gain the same set of
// in-plan affordances (drag endpoints, drag body via either side
// arrow, drag the midpoint sagitta to curve).
if (isSelected) {
children.push({
kind: 'endpoint-handle',
point: [node.start[0], node.start[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'start' as const },
})
children.push({
kind: 'endpoint-handle',
point: [node.end[0], node.end[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'end' as const },
})
if (isSplineFence(node) && node.path) {
// Spline fence: per control point draw (a) the symmetric tangent line
// through the point with a small handle dot on each end — dragging an
// end bends the curve on both sides via `move-tangent` — and (b) the
// larger control-point dot itself, which moves the point.
for (let i = 0; i < node.path.length; i += 1) {
const point = node.path[i]!
const handle = getFenceControlHandle(node.path, node.tangents, i)
// Scale the on-screen handle arm so even the default (small) tangent
// is grabbable; the affordance divides this back out on apply.
const armX = handle.x * TANGENT_HANDLE_ARM_SCALE
const armY = handle.y * TANGENT_HANDLE_ARM_SCALE
const out: [number, number] = [point[0] + armX, point[1] + armY]
const inn: [number, number] = [point[0] - armX, point[1] - armY]
// Connecting line (the "tangent" through the point). Violet to match
// the 3D tangent line + the handle dots.
children.push({
kind: 'line',
x1: inn[0],
y1: inn[1],
x2: out[0],
y2: out[1],
stroke: '#8381ed',
strokeWidth: 1.25,
strokeOpacity: 0.85,
vectorEffect: 'non-scaling-stroke',
})
// Handle dot on each end. Both drive the same `move-tangent`
// affordance; `side` tells it which end is being dragged so the
// stored OUT vector gets the correct sign.
children.push({
kind: 'endpoint-handle',
point: out,
state: 'idle',
variant: 'curve',
affordance: 'move-tangent',
payload: { fenceId: node.id, index: i, side: 'out' as const },
})
children.push({
kind: 'endpoint-handle',
point: inn,
state: 'idle',
variant: 'curve',
affordance: 'move-tangent',
payload: { fenceId: node.id, index: i, side: 'in' as const },
})
// The control-point dot last so it sits on top of the tangent line.
children.push({
kind: 'endpoint-handle',
point: [point[0], point[1]],
state: 'idle',
affordance: 'move-control-point',
payload: { fenceId: node.id, index: i },
})
}
} else {
children.push({
kind: 'endpoint-handle',
point: [node.start[0], node.start[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'start' as const },
})
children.push({
kind: 'endpoint-handle',
point: [node.end[0], node.end[1]],
state: 'idle',
affordance: 'move-endpoint',
payload: { fenceId: node.id, endpoint: 'end' as const },
})
}
// Two perpendicular `move-arrow` chevrons at the fence midpoint.
// No `affordance` → the registry layer routes pointer-down through
@@ -371,7 +459,8 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
const dz = node.end[1] - node.start[1]
const lineLength = Math.hypot(dx, dz)
if (lineLength > 1e-6) {
const frame = isCurvedWall(node) ? getWallCurveFrameAt(node, 0.5) : null
const frame =
isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null
const midX = frame ? frame.point.x : (node.start[0] + node.end[0]) / 2
const midZ = frame ? frame.point.y : (node.start[1] + node.end[1]) / 2
const nx = frame ? frame.normal.x : -dz / lineLength
@@ -393,29 +482,35 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo
// Curve sagitta handle — teal dot at the visual midpoint that drives
// `curveOffset`. Routes through `fenceCurveAffordance`. Fences host
// no children, so there's no equivalent of wall's curve-blocking
// check to gate this.
const curveHandle = getWallMidpointHandlePoint(node)
children.push({
kind: 'endpoint-handle',
point: [curveHandle.x, curveHandle.y],
state: 'idle',
variant: 'curve',
affordance: 'curve',
payload: { fenceId: node.id },
})
// check to gate this. Suppressed for spline fences: the single sagitta
// is meaningless against a multi-point curve.
if (!isSplineFence(node)) {
const curveHandle = getWallMidpointHandlePoint(node)
children.push({
kind: 'endpoint-handle',
point: [curveHandle.x, curveHandle.y],
state: 'idle',
variant: 'curve',
affordance: 'curve',
payload: { fenceId: node.id },
})
}
const length = getWallCurveLength(node)
const length = getFloorplanFenceLength(node)
if (length >= 0.1) {
const midX = (node.start[0] + node.end[0]) / 2
const midZ = (node.start[1] + node.end[1]) / 2
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const labelFrame =
isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null
const midX = labelFrame ? labelFrame.point.x : (node.start[0] + node.end[0]) / 2
const midZ = labelFrame ? labelFrame.point.y : (node.start[1] + node.end[1]) / 2
const angle = labelFrame
? Math.atan2(labelFrame.tangent.y, labelFrame.tangent.x)
: Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
children.push({
kind: 'dimension-label',
cx: midX,
cy: midZ,
text: `${Number.parseFloat(length.toFixed(2))}m`,
angle: Math.atan2(dz, dx),
angle,
})
}
}
@@ -0,0 +1,129 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
getSegmentGridStep,
isGridSnapActive,
markToolCancelConsumed,
snapScalarToGrid,
triggerSFX,
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
export const MoveFenceControlPointTool: React.FC<{
target: { fence: FenceNode; index: number }
}> = ({ target }) => {
const fenceId = target.fence.id as AnyNodeId
const index = target.index
const originalPath = target.fence.path ?? []
const originalPoint = originalPath[index] ?? target.fence.start
const [cursor, setCursor] = useState<[number, number, number]>([
originalPoint[0],
0,
originalPoint[1],
])
useEffect(() => {
pauseSceneHistory(useScene)
let committed = false
let lastPoint: [number, number] = [originalPoint[0], originalPoint[1]]
const buildPatch = (point: [number, number]): Partial<FenceNode> => {
const nextPath = originalPath.map((pathPoint, pathIndex) =>
pathIndex === index ? point : pathPoint,
)
const patch: Partial<FenceNode> = { path: nextPath }
if (index === 0) patch.start = point
if (index === nextPath.length - 1) patch.end = point
return patch
}
const previewPath = (point: [number, number]) => {
useLiveNodeOverrides.getState().set(fenceId, buildPatch(point))
useScene.getState().markDirty(fenceId)
}
const restore = () => {
useLiveNodeOverrides.getState().clear(fenceId)
useScene.getState().markDirty(fenceId)
}
const exit = (didCommit: boolean) => {
if (didCommit) triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [fenceId] })
useInteractionScope
.getState()
.endIf(
(scope) =>
scope.kind === 'reshaping' &&
scope.reshape === 'control-point' &&
scope.nodeId === fenceId &&
scope.index === index,
)
}
const onGridMove = (event: GridEvent) => {
const step = isGridSnapActive() ? getSegmentGridStep() : 0
const x = step > 0 ? snapScalarToGrid(event.localPosition[0], step) : event.localPosition[0]
const z = step > 0 ? snapScalarToGrid(event.localPosition[2], step) : event.localPosition[2]
if (x !== lastPoint[0] || z !== lastPoint[1]) {
if (step > 0) triggerSFX('sfx:grid-snap')
lastPoint = [x, z]
setCursor([x, 0, z])
previewPath([x, z])
}
}
const onGridClick = (event: GridEvent) => {
committed = true
resumeSceneHistory(useScene)
useScene.getState().updateNode(fenceId, buildPatch(lastPoint))
useLiveNodeOverrides.getState().clear(fenceId)
useScene.getState().markDirty(fenceId)
exit(true)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restore()
resumeSceneHistory(useScene)
markToolCancelConsumed()
exit(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!committed) {
restore()
resumeSceneHistory(useScene)
}
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [fenceId, index, originalPath, originalPoint, originalPoint[0], originalPoint[1]])
return (
<group>
<CursorSphere position={cursor} showTooltip={false} />
</group>
)
}
export default MoveFenceControlPointTool
@@ -0,0 +1,132 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
getSegmentGridStep,
isGridSnapActive,
markToolCancelConsumed,
snapScalarToGrid,
triggerSFX,
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
const TANGENT_HANDLE_ARM_SCALE = 3
export const MoveFenceTangentTool: React.FC<{
target: { fence: FenceNode; index: number; side: 'in' | 'out' }
}> = ({ target }) => {
const fenceId = target.fence.id as AnyNodeId
const { index, side } = target
const anchor = target.fence.path?.[index] ?? target.fence.start
const [cursor, setCursor] = useState<[number, number, number]>([anchor[0], 0, anchor[1]])
useEffect(() => {
pauseSceneHistory(useScene)
let committed = false
const originalTangents: Array<[number, number] | null> = (target.fence.tangents ?? []).map(
(t) => (t ? [t[0], t[1]] : null),
)
let lastTangents = originalTangents
const writeTangent = (vector: [number, number]) => {
const pathLength = target.fence.path?.length ?? originalTangents.length
const next: Array<[number, number] | null> = Array.from(
{ length: pathLength },
(_, tangentIndex) => lastTangents[tangentIndex] ?? null,
)
next[index] = vector
lastTangents = next
useLiveNodeOverrides.getState().set(fenceId, { tangents: next })
useScene.getState().markDirty(fenceId)
}
const restore = () => {
useLiveNodeOverrides.getState().clear(fenceId)
useScene.getState().markDirty(fenceId)
lastTangents = originalTangents
}
const exit = (didCommit: boolean) => {
if (didCommit) triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [fenceId] })
useInteractionScope
.getState()
.endIf(
(scope) =>
scope.kind === 'reshaping' &&
scope.reshape === 'tangent' &&
scope.nodeId === fenceId &&
scope.index === index &&
scope.side === side,
)
}
const onGridMove = (event: GridEvent) => {
const step = isGridSnapActive() ? getSegmentGridStep() : 0
const px = step > 0 ? snapScalarToGrid(event.localPosition[0], step) : event.localPosition[0]
const pz = step > 0 ? snapScalarToGrid(event.localPosition[2], step) : event.localPosition[2]
setCursor([px, 0, pz])
let armX = px - anchor[0]
let armZ = pz - anchor[1]
if (side === 'in') {
armX = -armX
armZ = -armZ
}
writeTangent([armX / TANGENT_HANDLE_ARM_SCALE, armZ / TANGENT_HANDLE_ARM_SCALE])
}
const onGridClick = (event: GridEvent) => {
committed = true
const finalTangents = lastTangents
resumeSceneHistory(useScene)
useScene.getState().updateNode(fenceId, { tangents: finalTangents })
useLiveNodeOverrides.getState().clear(fenceId)
useScene.getState().markDirty(fenceId)
lastTangents = finalTangents
exit(true)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restore()
resumeSceneHistory(useScene)
markToolCancelConsumed()
exit(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!committed) {
restore()
resumeSceneHistory(useScene)
}
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [anchor[0], anchor[1], fenceId, index, side, target.fence])
return (
<group>
<CursorSphere position={cursor} showTooltip={false} />
</group>
)
}
export default MoveFenceTangentTool
+110 -37
View File
@@ -7,7 +7,10 @@ import {
type FenceNode,
type GridEvent,
getPerpendicularWallMoveAxis,
isCurvedWall,
isSplineFence,
type LevelNode,
useLiveNodeOverrides,
useScene,
type WallMoveAxis,
type WallNode,
@@ -15,6 +18,8 @@ import {
import {
CursorSphere,
consumePlacementDragRelease,
getSegmentGridStep,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
snapFenceDraftPoint,
@@ -27,10 +32,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Phase 5 Stage D — fence whole-move tool.
*
* Live-drag pattern: translate the fence via direct scene updates while
* temporal history is paused. On commit we restore the original, resume
* history, apply the final position (single undo step), then re-pause.
* `constrainWallMoveDeltaToAxis` keeps moves axis-aligned.
* Live-drag pattern: preview data-driven reshapes through live node
* overrides. On commit we write the final position once for a single undo
* step.
* Straight fences use `constrainWallMoveDeltaToAxis` to keep side-moves
* perpendicular; curved fences translate freely because their path shape
* already carries direction.
*
* Wired via `def.affordanceTools.move`. The editor's `MoveTool`
* dispatcher picks this up before its legacy chain.
@@ -43,6 +50,7 @@ type LinkedFenceSnapshot = {
id: FenceNode['id']
start: [number, number]
end: [number, number]
path?: [number, number][]
}
function getLinkedFenceSnapshots(args: {
@@ -70,11 +78,32 @@ function getLinkedFenceSnapshots(args: {
id: node.id,
start: [...node.start] as [number, number],
end: [...node.end] as [number, number],
path: node.path?.map((point) => [...point] as [number, number]),
})
}
return snapshots
}
function translatePath(
path: [number, number][] | undefined,
deltaX: number,
deltaZ: number,
): [number, number][] | undefined {
return path?.map((point) => [point[0] + deltaX, point[1] + deltaZ])
}
function projectLinkedPath(
path: [number, number][] | undefined,
start: [number, number],
end: [number, number],
): [number, number][] | undefined {
if (!path || path.length === 0) return path
const nextPath = path.map((point) => [...point] as [number, number])
nextPath[0] = start
nextPath[nextPath.length - 1] = end
return nextPath
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
originalStart: [number, number],
@@ -82,19 +111,25 @@ function getLinkedFenceUpdates(
nextStart: [number, number],
nextEnd: [number, number],
) {
return linkedFences.map((fence) => ({
id: fence.id,
start: samePoint(fence.start, originalStart)
return linkedFences.map((fence) => {
const start = samePoint(fence.start, originalStart)
? nextStart
: samePoint(fence.start, originalEnd)
? nextEnd
: fence.start,
end: samePoint(fence.end, originalStart)
: fence.start
const end = samePoint(fence.end, originalStart)
? nextStart
: samePoint(fence.end, originalEnd)
? nextEnd
: fence.end,
}))
: fence.end
return {
id: fence.id,
start,
end,
path: projectLinkedPath(fence.path, start, end),
}
})
}
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
@@ -102,6 +137,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const originalPathRef = useRef(node.path?.map((point) => [...point] as [number, number]))
const meta =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
@@ -119,9 +155,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const previewRef = useRef<{
start: [number, number]
end: [number, number]
path?: [number, number][]
} | null>(null)
const canMoveFreely = isSplineFence(node) || isCurvedWall(node)
const moveAxisRef = useRef<WallMoveAxis | null>(
getPerpendicularWallMoveAxis(node.start, node.end),
canMoveFreely ? null : getPerpendicularWallMoveAxis(node.start, node.end),
)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
@@ -155,12 +196,38 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
updates: Array<{
id: FenceNode['id']
start: [number, number]
end: [number, number]
path?: [number, number][]
}>,
) => {
useLiveNodeOverrides
.getState()
.setMany(
updates.map((entry) => [
entry.id as AnyNodeId,
{ start: entry.start, end: entry.end, path: entry.path },
]),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyCommittedUpdates = (
updates: Array<{
id: FenceNode['id']
start: [number, number]
end: [number, number]
path?: [number, number][]
}>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
data: { start: entry.start, end: entry.end, path: entry.path },
})),
)
for (const entry of updates) {
@@ -169,19 +236,27 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
const restoreOriginal = () => {
applyNodePreview([
{ id: fenceId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
const overrides = useLiveNodeOverrides.getState()
overrides.clear(fenceId)
for (const linkedFence of linkedOriginalsRef.current) {
overrides.clear(linkedFence.id)
}
useScene.getState().markDirty(fenceId)
for (const linkedFence of linkedOriginalsRef.current) {
useScene.getState().markDirty(linkedFence.id as AnyNodeId)
}
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const deltaX = nextStart[0] - originalStart[0]
const deltaZ = nextStart[1] - originalStart[1]
const nextPath = translatePath(originalPathRef.current, deltaX, deltaZ)
previewRef.current = { start: nextStart, end: nextEnd, path: nextPath }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
const previewUpdates = [
{ id: fenceId, start: nextStart, end: nextEnd },
{ id: fenceId, start: nextStart, end: nextEnd, path: nextPath },
...getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
@@ -195,18 +270,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true
const gridSnapActive = isGridSnapActive()
const magneticSnapActive = isMagneticSnapActive()
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
ignoreFenceIds: [fenceId],
bypassSnap,
magnetic: !bypassSnap && isMagneticSnapActive(),
magnetic: magneticSnapActive,
step: gridSnapActive ? getSegmentGridStep() : 0,
})
if (
!bypassSnap &&
(gridSnapActive || magneticSnapActive) &&
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
@@ -217,11 +293,11 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
localX - anchor[0],
localZ - anchor[1],
moveAxisRef.current,
)
const rawDeltaX = localX - anchor[0]
const rawDeltaZ = localZ - anchor[1]
const [deltaX, deltaZ] = canMoveFreely
? [rawDeltaX, rawDeltaZ]
: constrainWallMoveDeltaToAxis(rawDeltaX, rawDeltaZ, moveAxisRef.current)
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
@@ -245,13 +321,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
return
}
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
restoreOriginal()
useScene.temporal.getState().resume()
applyNodePreview([
{ id: fenceId, start: preview.start, end: preview.end },
applyCommittedUpdates([
{ id: fenceId, start: preview.start, end: preview.end, path: preview.path },
...getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
@@ -260,6 +332,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
preview.end,
),
])
restoreOriginal()
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place')
@@ -316,7 +389,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [exitMoveMode, node])
}, [exitMoveMode, node, canMoveFreely])
return (
<group>
+15 -3
View File
@@ -1,4 +1,4 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import { isSplineFence, type ParametricDescriptor } from '@pascal-app/core'
import { FenceCurveEditor, FenceLengthEditor } from './inspector-editors'
import type { FenceNode } from './schema'
@@ -37,8 +37,20 @@ export const fenceParametrics: ParametricDescriptor<FenceNode> = {
{
label: 'Dimensions',
fields: [
{ key: 'length', kind: 'custom', component: FenceLengthEditor },
{ key: 'curve', kind: 'custom', component: FenceCurveEditor },
// Length / Curve drive start/end + the single sagitta — meaningless
// for a multi-point spline fence, so hide them when `path` is set.
{
key: 'length',
kind: 'custom',
component: FenceLengthEditor,
visibleIf: (n) => !isSplineFence(n),
},
{
key: 'curve',
kind: 'custom',
component: FenceCurveEditor,
visibleIf: (n) => !isSplineFence(n),
},
{ key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 4, step: 0.05 },
{ key: 'thickness', kind: 'number', unit: 'm', min: 0.03, max: 0.5, step: 0.005 },
],
+142 -1
View File
@@ -6,10 +6,12 @@ import {
emitter,
type FenceNode,
type GridEvent,
getTwoPointFenceCurveTangents,
getWallMiterBoundaryPoints,
type LevelNode,
type Point2D,
resolveAlignment,
sampleFenceSpline,
useScene,
type WallMiterData,
type WallNode,
@@ -17,6 +19,7 @@ import {
import {
CursorSphere,
createFenceOnCurrentLevel,
createSplineFenceOnCurrentLevel,
EDITOR_LAYER,
type FencePlanPoint,
formatAngleRadians,
@@ -24,14 +27,18 @@ import {
getAngleArcToSegmentReference,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
getSegmentGridStep,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
type SegmentAngleReference,
snapFenceDraftPoint,
snapScalarToGrid,
triggerSFX,
useAlignmentGuides,
useEditor,
useFenceCurveDraft,
useSegmentDraftChain,
} from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
@@ -418,7 +425,7 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
const { nodes } = useScene.getState()
if (!currentLevelId) return { walls: [], fences: [] }
const levelNode = nodes[currentLevelId]
if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] }
if (levelNode?.type !== 'level') return { walls: [], fences: [] }
const children = (levelNode as LevelNode).children.map((childId) => nodes[childId])
return {
walls: children.filter((n): n is WallNode => n?.type === 'wall'),
@@ -427,6 +434,14 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
}
export const FenceTool: React.FC = () => {
const fenceMode = useEditor((s) => s.continuationByContext.fence)
if (fenceMode === 'curved') {
return <SplineFenceDraft />
}
return <StraightFenceTool />
}
const StraightFenceTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
// A placed preset seeds `toolDefaults.fence` before the tool mounts, so
@@ -693,6 +708,132 @@ export const FenceTool: React.FC = () => {
)
}
const SPLINE_PREVIEW_COLOR = '#8381ed'
const SPLINE_PREVIEW_SEGMENTS = 40
const SplineFenceDraft: React.FC = () => {
const previewHeight =
typeof useEditor.getState().toolDefaults.fence?.height === 'number'
? (useEditor.getState().toolDefaults.fence?.height as number)
: FENCE_PREVIEW_HEIGHT
const [draftPoints, setDraftPoints] = useState<FencePlanPoint[]>([])
const [cursor, setCursor] = useState<FencePlanPoint | null>(null)
const draftRef = useRef(draftPoints)
draftRef.current = draftPoints
// Mirror the draft length into the HUD store so the "finish curve" hint only
// shows once drafting has started; always clear it when the tool unmounts.
useEffect(() => {
useFenceCurveDraft.getState().setPointCount(draftPoints.length)
}, [draftPoints])
useEffect(() => () => useFenceCurveDraft.getState().reset(), [])
useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), [])
useEffect(() => {
const snapPoint = (local: FencePlanPoint): FencePlanPoint => {
const step = isGridSnapActive() ? getSegmentGridStep() : 0
if (step <= 0) return local
return [snapScalarToGrid(local[0], step), snapScalarToGrid(local[1], step)]
}
const commit = () => {
const points = draftRef.current
if (points.length >= 2) {
const created = createSplineFenceOnCurrentLevel(points)
if (created) {
triggerSFX('sfx:item-place')
// Once the new curve fence is selected for direct editing, leave
// placement mode so the toolbar matches the active interaction.
useViewer.getState().setSelection({ selectedIds: [created.id] })
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
}
setDraftPoints([])
setCursor(null)
}
const onMove = (event: GridEvent) => {
setCursor(snapPoint([event.localPosition[0], event.localPosition[2]]))
}
const onClick = (event: GridEvent) => {
if (event.nativeEvent.detail >= 2) {
commit()
return
}
const point = snapPoint([event.localPosition[0], event.localPosition[2]])
triggerSFX('sfx:grid-snap')
setDraftPoints((prev) => [...prev, point])
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') commit()
}
const onCancel = () => {
if (draftRef.current.length === 0) return
markToolCancelConsumed()
setDraftPoints((prev) => prev.slice(0, -1))
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [])
const previewPoints = cursor ? [...draftPoints, cursor] : draftPoints
const curveGeometry = useMemo(() => {
if (previewPoints.length < 2) return null
const sampled = sampleFenceSpline(
previewPoints,
getTwoPointFenceCurveTangents(previewPoints),
SPLINE_PREVIEW_SEGMENTS,
)
return new BufferGeometry().setFromPoints(
sampled.map((point) => new Vector3(point.x, previewHeight, point.y)),
)
}, [previewHeight, previewPoints])
return (
<group>
{cursor && <CursorSphere height={previewHeight} position={[cursor[0], 0, cursor[1]]} />}
{draftPoints.map((point, index) => (
<mesh
key={`fence-spline-pt-${index}`}
layers={EDITOR_LAYER}
position={[point[0], previewHeight, point[1]]}
>
<sphereGeometry args={[0.07, 16, 12]} />
<meshBasicMaterial color={SPLINE_PREVIEW_COLOR} depthTest={false} />
</mesh>
))}
{curveGeometry && (
// @ts-expect-error - R3F accepts Three line primitives here.
<line frustumCulled={false} geometry={curveGeometry} layers={EDITOR_LAYER} renderOrder={2}>
<lineBasicNodeMaterial
color={SPLINE_PREVIEW_COLOR}
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.95}
transparent
/>
</line>
)}
</group>
)
}
function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) {
const geometry = useMemo(() => {
const segmentCount = Math.max(
@@ -76,7 +76,7 @@ export default function DownspoutsPanel() {
}),
)
if (!gutter || gutter.type !== 'gutter') return null
if (gutter?.type !== 'gutter') return null
const handleSelectDownspout = (id: AnyNodeId) => {
setSelection({ selectedIds: [id] })
+3 -3
View File
@@ -31,10 +31,10 @@ export function buildGutterFloorplan(
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
// Compose roof → segment → gutter in plan coords. Each rotation is
// negated so SVG's y-down CW matches Three.js' top-down CCW — the same
@@ -111,7 +111,7 @@ export function buildGutterFloorplan(
const mitreSiblings: GutterWithSegment[] = []
for (const segId of roof.children ?? []) {
const sib = ctx.resolve(segId as AnyNodeId) as RoofSegmentNode | undefined
if (!sib || sib.type !== 'roof-segment') continue
if (sib?.type !== 'roof-segment') continue
for (const gid of sib.children ?? []) {
const g = ctx.resolve(gid as AnyNodeId) as GutterNode | undefined
if (g && g.type === 'gutter' && g.id !== node.id) {
+1 -1
View File
@@ -104,7 +104,7 @@ export function snapLengthToCorner(
const roofChildren = (roof as { children?: readonly string[] } | undefined)?.children
for (const sid of roofChildren ?? []) {
const s = sceneApi.get<RoofSegmentNode>(sid as AnyNodeId)
if (!s || s.type !== 'roof-segment') continue
if (s?.type !== 'roof-segment') continue
const tf = segmentTransform(s)
for (const gid of s.children ?? []) {
const g = sceneApi.get(gid as AnyNodeId)
+18 -1
View File
@@ -19,6 +19,7 @@ import {
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner-mitre'
import { computeSharedEaveY } from './eave-align'
import { computeEaveY } from './eave-snap'
@@ -200,6 +201,22 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => {
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
// Map gutter-local geometry into the host segment's local frame (where the
// trim cut prisms live) — same pose the inner mesh group is mounted with
// (position [x, liveEaveY, z] + yaw). Computed before the early return so the
// hook order stays stable.
const liveEaveYForClip = sharedEaveY ?? (effectiveSegment ? computeEaveY(effectiveSegment) : 0)
const localToSegment = useMemo(
() =>
new THREE.Matrix4().compose(
new THREE.Vector3(node.position[0] ?? 0, liveEaveYForClip, node.position[2] ?? 0),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), node.rotation ?? 0),
new THREE.Vector3(1, 1, 1),
),
[node.position[0], node.position[2], node.rotation, liveEaveYForClip],
)
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment)
if (!segment || !effectiveSegment) return null
// `node.position` is segment-local — the placement tool resolves the
@@ -232,7 +249,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => {
>
<mesh
castShadow
geometry={geometry}
geometry={clippedGeometry ?? geometry}
material={material}
name="gutter-surface"
receiveShadow
+1 -1
View File
@@ -107,7 +107,7 @@ function commitNewSceneMaterialAndSlots(
useScene.setState((state) => {
if (state.readOnly) return state
const currentNode = state.nodes[nodeId]
if (!currentNode || currentNode.type !== 'item') return state
if (currentNode?.type !== 'item') return state
return {
materials: { ...state.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial },
nodes: {
+1 -1
View File
@@ -226,7 +226,7 @@ function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null
const scene = useScene.getState()
const linesets: LinesetNode[] = []
for (const n of Object.values(scene.nodes)) {
if (!n || n.type !== 'lineset') continue
if (n?.type !== 'lineset') continue
if ((n.parentId as AnyNodeId | null) !== levelId) continue
const ls = n as LinesetNode
if (ls.path.length >= 2) linesets.push(ls)
+1 -1
View File
@@ -59,7 +59,7 @@ export const pipeTrapDefinition: NodeDefinition<typeof PipeTrapNode> = {
presentation: {
label: 'Trap',
description: 'DWV P-trap — water seal on the waste line. The trap arm runs to the vent.',
icon: { kind: 'iconify', name: 'lucide:spline' },
icon: { kind: 'url', src: '/icons/dwv-pipes.webp' },
paletteSection: 'structure',
paletteOrder: 98,
},
@@ -1,7 +1,65 @@
import { describe, expect, test } from 'bun:test'
import { getDutchRoofMetrics, getRidgeVentLinesForSegment, RoofSegmentNode } from '@pascal-app/core'
import type * as THREE from 'three'
import { getRoofTopSurfaceY } from '../../shared/roof-surface'
import { buildRidgeVentGeometry } from '../geometry'
import { RidgeVentNode } from '../schema'
function minYAtLocalPoint(geo: THREE.BufferGeometry, targetX: number, targetZ: number): number {
const pos = geo.getAttribute('position').array as Float32Array
let minY = Infinity
for (let i = 0; i < pos.length; i += 3) {
if (Math.abs(pos[i]! - targetX) <= 1e-5 && Math.abs(pos[i + 2]! - targetZ) <= 1e-5) {
minY = Math.min(minY, pos[i + 1]!)
}
}
return minY
}
function xBounds(geo: THREE.BufferGeometry): { minX: number; maxX: number } {
const pos = geo.getAttribute('position').array as Float32Array
let minX = Infinity
let maxX = -Infinity
for (let i = 0; i < pos.length; i += 3) {
minX = Math.min(minX, pos[i]!)
maxX = Math.max(maxX, pos[i]!)
}
return { minX, maxX }
}
function maxAbsZNearX(geo: THREE.BufferGeometry, targetX: number, tolerance = 0.03): number {
const pos = geo.getAttribute('position').array as Float32Array
let maxAbsZ = 0
for (let i = 0; i < pos.length; i += 3) {
if (Math.abs(pos[i]! - targetX) <= tolerance) {
maxAbsZ = Math.max(maxAbsZ, Math.abs(pos[i + 2]!))
}
}
return maxAbsZ
}
function expectFinitePositions(geo: THREE.BufferGeometry): void {
const pos = geo.getAttribute('position').array as Float32Array
for (let i = 0; i < pos.length; i++) {
expect(Number.isFinite(pos[i])).toBe(true)
}
}
function rotatedSurfaceYAt(
segment: RoofSegmentNode,
centerX: number,
centerZ: number,
rotation: number,
localX: number,
localZ: number,
): number {
return getRoofTopSurfaceY(
centerX + localX * Math.cos(rotation) + localZ * Math.sin(rotation),
centerZ - localX * Math.sin(rotation) + localZ * Math.cos(rotation),
segment,
)
}
describe('buildRidgeVentGeometry', () => {
test('returns geometry with matching position / normal / uv counts', () => {
const geo = buildRidgeVentGeometry(RidgeVentNode.parse({}))
@@ -38,14 +96,282 @@ describe('buildRidgeVentGeometry', () => {
test('length scales the X bounds proportionally', () => {
const geo = buildRidgeVentGeometry(RidgeVentNode.parse({ length: 4, endCaps: false }))
const pos = geo.getAttribute('position').array as Float32Array
let maxX = -Infinity
let minX = Infinity
for (let i = 0; i < pos.length; i += 3) {
if (pos[i]! > maxX) maxX = pos[i]!
if (pos[i]! < minX) minX = pos[i]!
}
const { minX, maxX } = xBounds(geo)
expect(maxX).toBeCloseTo(2)
expect(minX).toBeCloseTo(-2)
})
test('legacy partial ridge vents never produce NaN positions', () => {
const geo = buildRidgeVentGeometry({
id: 'rvent_legacy',
type: 'ridge-vent',
} as unknown as Parameters<typeof buildRidgeVentGeometry>[0])
expect(geo.getAttribute('position').count).toBeGreaterThan(0)
expectFinitePositions(geo)
})
test('clips rendered length to host segment trim without mutating the stored length', () => {
const segment = RoofSegmentNode.parse({
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
trim: { left: 2, right: 1 },
})
const vent = RidgeVentNode.parse({
length: 8,
position: [0, 0, 0],
rotation: 0,
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const { minX, maxX } = xBounds(geo)
expect(vent.length).toBe(8)
expect(minX).toBeCloseTo(-2)
expect(maxX).toBeCloseTo(3)
})
test('clips rendered ridge vents against diagonal trim planes', () => {
const segment = RoofSegmentNode.parse({
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
trim: { frontLeftX: 2, frontLeftZ: 4 },
})
const vent = RidgeVentNode.parse({
length: 8,
width: 0.3,
position: [0, 0, 0],
rotation: 0,
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const { minX, maxX } = xBounds(geo)
expect(vent.length).toBe(8)
expect(vent.position[0]).toBe(0)
expect(minX).toBeCloseTo(-3.575, 3)
expect(maxX).toBeCloseTo(4)
})
test('seats the underside onto rendered roof top faces when a segment is provided', () => {
const segment = RoofSegmentNode.parse({
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const vent = RidgeVentNode.parse({
width: 0.4,
height: 0.12,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const halfWidth = vent.width / 2
const ridgeY = getRoofTopSurfaceY(0, 0, segment)
expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(
getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY,
)
expect(minYAtLocalPoint(geo, -halfLength, halfWidth)).toBeCloseTo(
getRoofTopSurfaceY(-halfLength, halfWidth, segment) - ridgeY,
)
})
test('seats the underside using the vent rotation for diagonal hip caps', () => {
const segment = RoofSegmentNode.parse({
roofType: 'hip',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const rotation = Math.PI / 4
const centerX = -2.5
const centerZ = 1.5
const vent = RidgeVentNode.parse({
position: [centerX, 0, centerZ],
rotation,
width: 0.4,
height: 0.12,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const startHalfWidth = maxAbsZNearX(geo, -halfLength, 1e-5)
const ridgeY = rotatedSurfaceYAt(segment, centerX, centerZ, rotation, 0, 0)
expect(startHalfWidth).toBeGreaterThan(0)
expect(minYAtLocalPoint(geo, -halfLength, -startHalfWidth)).toBeCloseTo(
rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, -startHalfWidth) - ridgeY,
)
expect(minYAtLocalPoint(geo, -halfLength, startHalfWidth)).toBeCloseTo(
rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, startHalfWidth) - ridgeY,
)
})
test('seats diagonal hip cap ends at different roof heights along the slope', () => {
const segment = RoofSegmentNode.parse({
roofType: 'hip',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const rotation = Math.PI / 4
const centerX = -2.5
const centerZ = 1.5
const vent = RidgeVentNode.parse({
position: [centerX, 0, centerZ],
rotation,
length: Math.SQRT2,
width: 0.4,
height: 0.12,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const startHalfWidth = maxAbsZNearX(geo, -halfLength, 1e-5)
const endHalfWidth = maxAbsZNearX(geo, halfLength, 1e-5)
const ridgeY = rotatedSurfaceYAt(segment, centerX, centerZ, rotation, 0, 0)
const leftEndY =
rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, -startHalfWidth) - ridgeY
const rightEndY =
rotatedSurfaceYAt(segment, centerX, centerZ, rotation, halfLength, -endHalfWidth) - ridgeY
expect(startHalfWidth).toBeGreaterThan(0)
expect(endHalfWidth).toBeGreaterThan(0)
expect(leftEndY).not.toBeCloseTo(rightEndY)
expect(minYAtLocalPoint(geo, -halfLength, -startHalfWidth)).toBeCloseTo(leftEndY)
expect(minYAtLocalPoint(geo, halfLength, -endHalfWidth)).toBeCloseTo(rightEndY)
})
test('seats the dutch top ridge with the same sloped cap profile as other ridge vents', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const vent = RidgeVentNode.parse({
name: 'Ridge Vent',
position: [0, 0, 0],
rotation: 0,
length: 5,
width: 0.3,
height: 0.1,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const halfWidth = vent.width / 2
const ridgeY = getRoofTopSurfaceY(0, 0, segment)
const rawRoofDrop = getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY
expect(rawRoofDrop).toBeLessThan(-0.05)
expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(rawRoofDrop)
})
test('keeps an extended Dutch top ridge level through the rake span instead of drooping onto the hip', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
})
const metrics = getDutchRoofMetrics(segment)
const rakeReach = Math.min(
segment.dutchGabletRake,
Math.max(0, segment.width / 2 - metrics.waistHalfX) * 0.98,
)
const vent = RidgeVentNode.parse({
name: 'Ridge Vent',
position: [0, 0, 0],
rotation: 0,
length: (metrics.waistHalfX + rakeReach) * 2,
width: 0.3,
height: 0.1,
style: 'shingled',
endCaps: false,
})
const geo = buildRidgeVentGeometry(vent, segment)
const halfLength = vent.length / 2
const halfWidth = vent.width / 2
const ridgeY = getRoofTopSurfaceY(0, 0, segment)
const rawRoofDrop = getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY
const supportedDrop = getRoofTopSurfaceY(-metrics.waistHalfX, -halfWidth, segment) - ridgeY
expect(rawRoofDrop).toBeLessThan(supportedDrop - 0.05)
expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(supportedDrop)
expect(minYAtLocalPoint(geo, halfLength, -halfWidth)).toBeCloseTo(supportedDrop)
})
test('tapers Dutch hip ridge vent ends to a small capped nose without shortening the support-line length', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
wallHeight: 0.5,
pitch: 45,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const hipLine = getRidgeVentLinesForSegment(segment).find(
(line) => line.name === 'Hip Ridge Vent' && line.start[0] < 0 && line.start[1] < 0,
)
expect(hipLine).toBeDefined()
const line = hipLine!
const rotation = Math.atan2(-(line.end[1] - line.start[1]), line.end[0] - line.start[0])
const vent = RidgeVentNode.parse({
name: 'Hip Ridge Vent',
position: [(line.start[0] + line.end[0]) / 2, 0, (line.start[1] + line.end[1]) / 2],
rotation,
length: Math.hypot(line.end[0] - line.start[0], line.end[1] - line.start[1]),
width: 0.3,
height: 0.1,
style: 'shingled',
endCaps: true,
})
const geo = buildRidgeVentGeometry(vent, segment)
const bounds = xBounds(geo)
const fullHalfLength = vent.length / 2
const startWidth = maxAbsZNearX(geo, bounds.minX)
const endWidth = maxAbsZNearX(geo, bounds.maxX)
const bodyWidth = maxAbsZNearX(geo, 0, 0.08)
expect(bounds.minX).toBeLessThanOrEqual(-fullHalfLength + 0.02)
expect(bounds.maxX).toBeGreaterThan(fullHalfLength - 0.15)
expect(startWidth).toBeGreaterThan(0.03)
expect(startWidth).toBeLessThan(0.09)
expect(endWidth).toBeGreaterThan(0.1)
expect(bodyWidth).toBeGreaterThan(startWidth + 0.015)
})
})
+2 -2
View File
@@ -152,8 +152,8 @@ const ridgeVentHandles: HandleDescriptor<RidgeVentNodeType>[] = [
* geometry builder shared with the placement preview + future tests,
* no animation or per-frame system.
*
* The placement tool snaps to the ridge (segment-local Z=0) wherever
* the cursor lands on a segment.
* The placement tool snaps to the nearest ridge/break line wherever the
* cursor lands on a segment.
*/
export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
kind: 'ridge-vent',
+2 -2
View File
@@ -29,10 +29,10 @@ export function buildRidgeVentFloorplan(
ctx: GeometryContext,
): FloorplanGeometry | null {
const segment = ctx.parent as RoofSegmentNode | null
if (!segment || segment.type !== 'roof-segment') return null
if (segment?.type !== 'roof-segment') return null
const roofId = segment.parentId as AnyNodeId | null
const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined
if (!roof || roof.type !== 'roof') return null
if (roof?.type !== 'roof') return null
const cosR = Math.cos(-roof.rotation)
const sinR = Math.sin(-roof.rotation)
+575 -76
View File
@@ -1,8 +1,46 @@
import type { RidgeVentNode } from '@pascal-app/core'
import {
getDutchRoofMetrics,
getRidgeVentLinesForSegment,
normalizeRoofSegmentTrim,
type RidgeVentNode,
type RoofSegmentNode,
} from '@pascal-app/core'
import * as THREE from 'three'
import { getRoofTopSurfaceY } from '../shared/roof-surface'
const ARC_SEGS = 16
const SHINGLED_TAB_SIZE = 0.3
const DEFAULT_RIDGE_VENT_LENGTH = 2
const DEFAULT_RIDGE_VENT_WIDTH = 0.3
const DEFAULT_RIDGE_VENT_HEIGHT = 0.1
type ProfilePoint = [z: number, capY: number]
type RidgeVentGeometryVertex = {
x: number
y: number
z: number
nx: number
ny: number
nz: number
u: number
v: number
}
type SegmentTrimClipPlane = {
signedDistance: (segmentX: number, segmentZ: number) => number
}
type RidgeVentSupportLine = {
startX: number
endX: number
name: string
taperAtStart: boolean
taperAtEnd: boolean
}
type RidgeVentEndTaper = {
taperAtStart: boolean
taperAtEnd: boolean
taperLength: number
tipHalfWidth: number
}
/**
* Pure builder for the ridge vent mesh. Each style is a peaked **band** of
@@ -22,32 +60,136 @@ const SHINGLED_TAB_SIZE = 0.3
*
* `endCaps` closes both ends. Pure: no React, no scene access, no mutation.
*/
export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometry {
const halfLen = node.length / 2
const halfW = node.width / 2
const h = node.height
export function buildRidgeVentGeometry(
node: RidgeVentNode,
segment?: RoofSegmentNode,
): THREE.BufferGeometry {
const length = finitePositive(node.length, DEFAULT_RIDGE_VENT_LENGTH)
const width = finitePositive(node.width, DEFAULT_RIDGE_VENT_WIDTH)
const h = finitePositive(node.height, DEFAULT_RIDGE_VENT_HEIGHT)
const halfLen = length / 2
const halfW = width / 2
// Band thickness. Generous enough to read as a solid cap; the eave faces
// are `t` tall, which is the depth the user actually sees from the side.
const t = Math.max(0.02, h * 0.4)
const centerX = finiteNumber(node.position?.[0], 0)
const centerZ = finiteNumber(node.position?.[2], 0)
const rotationY = finiteNumber(node.rotation, 0)
const sinR = Math.sin(rotationY)
const cosR = Math.cos(rotationY)
const dutchTopRidgeSupport = getDutchTopRidgeSupport(segment, centerX, centerZ, rotationY)
const surfaceYAt = (x: number, z: number) => {
if (!segment) return 0
let sampleX = centerX + x * cosR + z * sinR
let sampleZ = centerZ - x * sinR + z * cosR
if (dutchTopRidgeSupport) {
if (dutchTopRidgeSupport.axis === 'x') {
sampleX = clamp(
sampleX,
-dutchTopRidgeSupport.innerHalfSpan,
dutchTopRidgeSupport.innerHalfSpan,
)
} else {
sampleZ = clamp(
sampleZ,
-dutchTopRidgeSupport.innerHalfSpan,
dutchTopRidgeSupport.innerHalfSpan,
)
}
}
return getRoofTopSurfaceY(sampleX, sampleZ, segment)
}
const ridgeY = surfaceYAt(0, 0)
const seatYAt = (x: number, z: number) => (segment ? surfaceYAt(x, z) - ridgeY : 0)
const top =
node.style === 'metal'
? metalTop(halfW, h, t)
: node.style === 'shingled'
? shingledTop(halfW, h, t)
: standardTop(halfW, h, t)
const supportLine = segment ? getSupportLineForVent(segment, node) : null
const endTaper = getRidgeVentEndTaper(supportLine, width, h)
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
buildBand(positions, normals, uvs, top, t, halfLen, node.endCaps)
buildBand(positions, normals, uvs, top, seatYAt, -halfLen, halfLen, node.endCaps, endTaper)
if (node.style === 'shingled') {
addShingledTabs(positions, normals, uvs, halfLen, top, h)
addShingledTabs(positions, normals, uvs, -halfLen, halfLen, top, h, seatYAt, endTaper)
}
return buildBufferGeometry(positions, normals, uvs)
const geometry = buildBufferGeometry(positions, normals, uvs)
if (!segment) return geometry
const clipped = clipRidgeVentGeometryToSegmentTrim(geometry, node, segment)
if (clipped !== geometry) geometry.dispose()
return clipped
}
function finiteNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function finitePositive(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function getRidgeVentEndTaper(
supportLine: RidgeVentSupportLine | null,
width: number,
height: number,
): RidgeVentEndTaper | null {
if (
!supportLine ||
(supportLine.name !== 'Hip Ridge Vent' && supportLine.name !== 'Slope Ridge Vent')
) {
return null
}
const lineLength = supportLine.endX - supportLine.startX
const taperLength = Math.min(
Math.max(0.07, width * 0.45, height * 0.9),
Math.max(0, lineLength / 2 - 0.02),
)
if (!(taperLength > 0.001)) return null
return {
taperAtStart: supportLine.taperAtStart,
taperAtEnd: supportLine.taperAtEnd,
taperLength,
tipHalfWidth: Math.min(width * 0.34, Math.max(0.04, width * 0.26)),
}
}
function getDutchTopRidgeSupport(
segment: RoofSegmentNode | undefined,
centerX: number,
centerZ: number,
rotationY: number,
): { axis: 'x' | 'z'; innerHalfSpan: number } | null {
if (segment?.roofType !== 'dutch') return null
const metrics = getDutchRoofMetrics(segment)
const onWidthAxisTopRidge =
metrics.axis === 'x' && Math.abs(centerZ) <= 1e-4 && Math.abs(Math.sin(rotationY)) <= 1e-4
const onDepthAxisTopRidge =
metrics.axis === 'z' && Math.abs(centerX) <= 1e-4 && Math.abs(Math.cos(rotationY)) <= 1e-4
if (onWidthAxisTopRidge) {
return { axis: 'x', innerHalfSpan: metrics.waistHalfX }
}
if (onDepthAxisTopRidge) {
return { axis: 'z', innerHalfSpan: metrics.waistHalfZ }
}
return null
}
// ─── Top profiles (open polylines eave → peak → eave, in [z, y]) ─────────
@@ -55,8 +197,8 @@ export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometr
// eaves, seating the cap on the roof while leaving a peaked void beneath.
// Smooth rounded arch.
function standardTop(halfW: number, h: number, t: number): [number, number][] {
const pts: [number, number][] = []
function standardTop(halfW: number, h: number, t: number): ProfilePoint[] {
const pts: ProfilePoint[] = []
for (let i = 0; i <= ARC_SEGS; i++) {
const frac = i / ARC_SEGS
const z = -halfW + frac * 2 * halfW
@@ -67,7 +209,7 @@ function standardTop(halfW: number, h: number, t: number): [number, number][] {
}
// Angular peak with a narrow flat ridge at the top.
function shingledTop(halfW: number, h: number, t: number): [number, number][] {
function shingledTop(halfW: number, h: number, t: number): ProfilePoint[] {
const peakHalf = halfW * 0.12
return [
[-halfW, t],
@@ -78,7 +220,7 @@ function shingledTop(halfW: number, h: number, t: number): [number, number][] {
}
// Bent-metal cap: steep folds up to a wide flat standing seam.
function metalTop(halfW: number, h: number, t: number): [number, number][] {
function metalTop(halfW: number, h: number, t: number): ProfilePoint[] {
const seamHalf = halfW * 0.5
const shoulderY = t + (h - t) * 0.5
return [
@@ -97,78 +239,97 @@ function buildBand(
positions: number[],
normals: number[],
uvs: number[],
top: [number, number][],
t: number,
halfLen: number,
top: ProfilePoint[],
seatYAt: (x: number, z: number) => number,
startX: number,
endX: number,
withCaps: boolean,
endTaper: RidgeVentEndTaper | null = null,
): void {
const n = top.length
// Underside: the same profile dropped straight down by `t` (eaves → y 0).
const inner: [number, number][] = top.map(([z, y]) => [z, y - t])
const halfWidth = getProfileHalfWidth(top)
const stations = getRidgeVentSweepStations(startX, endX, endTaper)
const scaledZAt = (x: number, z: number): number =>
z * getProfileScaleAtX(x, startX, endX, halfWidth, endTaper)
const seatAt = (x: number, z: number): number => seatYAt(x, scaledZAt(x, z))
const topAt = (x: number, z: number, capY: number): number => seatAt(x, z) + capY
// Top surface + underside, swept along the ridge length.
for (let i = 0; i < n - 1; i++) {
const [z0, y0] = top[i]!
const [z1, y1] = top[i + 1]!
pushQuad(
positions,
normals,
uvs,
[-halfLen, y0, z0],
[halfLen, y0, z0],
[halfLen, y1, z1],
[-halfLen, y1, z1],
[0, 1, 0],
)
const [iz0, iy0] = inner[i]!
const [iz1, iy1] = inner[i + 1]!
pushQuad(
positions,
normals,
uvs,
[-halfLen, iy0, iz0],
[halfLen, iy0, iz0],
[halfLen, iy1, iz1],
[-halfLen, iy1, iz1],
[0, -1, 0],
)
for (let station = 0; station < stations.length - 1; station += 1) {
const x0 = stations[station]!
const x1 = stations[station + 1]!
for (let i = 0; i < n - 1; i++) {
const [z0, capY0] = top[i]!
const [z1, capY1] = top[i + 1]!
const x0z0 = scaledZAt(x0, z0)
const x1z0 = scaledZAt(x1, z0)
const x1z1 = scaledZAt(x1, z1)
const x0z1 = scaledZAt(x0, z1)
pushQuad(
positions,
normals,
uvs,
[x0, topAt(x0, z0, capY0), x0z0],
[x1, topAt(x1, z0, capY0), x1z0],
[x1, topAt(x1, z1, capY1), x1z1],
[x0, topAt(x0, z1, capY1), x0z1],
[0, 1, 0],
)
pushQuad(
positions,
normals,
uvs,
[x0, seatAt(x0, z0), x0z0],
[x1, seatAt(x1, z0), x1z0],
[x1, seatAt(x1, z1), x1z1],
[x0, seatAt(x0, z1), x0z1],
[0, -1, 0],
)
}
}
// Eave thickness faces (the visible depth along each long edge).
for (const idx of [0, n - 1]) {
const [z, yTop] = top[idx]!
const [, yInner] = inner[idx]!
const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1]
pushQuad(
positions,
normals,
uvs,
[-halfLen, yInner, z],
[halfLen, yInner, z],
[halfLen, yTop, z],
[-halfLen, yTop, z],
hint,
)
for (let station = 0; station < stations.length - 1; station += 1) {
const x0 = stations[station]!
const x1 = stations[station + 1]!
for (const idx of [0, n - 1]) {
const [z, capY] = top[idx]!
const x0z = scaledZAt(x0, z)
const x1z = scaledZAt(x1, z)
const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1]
pushQuad(
positions,
normals,
uvs,
[x0, seatAt(x0, z), x0z],
[x1, seatAt(x1, z), x1z],
[x1, topAt(x1, z, capY), x1z],
[x0, topAt(x0, z, capY), x0z],
hint,
)
}
}
// End caps: the band's cross-section ring at each end.
if (withCaps) {
for (const sign of [-1, 1] as const) {
const x = sign * halfLen
for (const [x, sign] of [
[startX, -1],
[endX, 1],
] as const) {
const hint: [number, number, number] = [sign, 0, 0]
for (let i = 0; i < n - 1; i++) {
const [z0, y0] = top[i]!
const [z1, y1] = top[i + 1]!
const [iz0, iy0] = inner[i]!
const [iz1, iy1] = inner[i + 1]!
const [z0, capY0] = top[i]!
const [z1, capY1] = top[i + 1]!
const scaledZ0 = scaledZAt(x, z0)
const scaledZ1 = scaledZAt(x, z1)
pushQuad(
positions,
normals,
uvs,
[x, y0, z0],
[x, y1, z1],
[x, iy1, iz1],
[x, iy0, iz0],
[x, topAt(x, z0, capY0), scaledZ0],
[x, topAt(x, z1, capY1), scaledZ1],
[x, seatAt(x, z1), scaledZ1],
[x, seatAt(x, z0), scaledZ0],
hint,
)
}
@@ -176,6 +337,45 @@ function buildBand(
}
}
function getProfileHalfWidth(top: ProfilePoint[]): number {
return top.reduce((halfWidth, [z]) => Math.max(halfWidth, Math.abs(z)), 0)
}
function getRidgeVentSweepStations(
startX: number,
endX: number,
endTaper: RidgeVentEndTaper | null,
): number[] {
const stations = [startX, endX]
if (endTaper?.taperAtStart) stations.push(startX + endTaper.taperLength)
if (endTaper?.taperAtEnd) stations.push(endX - endTaper.taperLength)
return stations
.filter((x) => x >= startX && x <= endX)
.sort((a, b) => a - b)
.filter((x, index, sorted) => index === 0 || Math.abs(x - sorted[index - 1]!) > 1e-5)
}
function getProfileScaleAtX(
x: number,
startX: number,
endX: number,
halfWidth: number,
endTaper: RidgeVentEndTaper | null,
): number {
if (!endTaper || !(halfWidth > 0.0001)) return 1
const tipScale = clamp(endTaper.tipHalfWidth / halfWidth, 0, 1)
if (endTaper.taperAtStart && x <= startX + endTaper.taperLength) {
const progress = clamp((x - startX) / endTaper.taperLength, 0, 1)
return lerp(tipScale, 1, progress)
}
if (endTaper.taperAtEnd && x >= endX - endTaper.taperLength) {
const progress = clamp((endX - x) / endTaper.taperLength, 0, 1)
return lerp(tipScale, 1, progress)
}
return 1
}
// ─── Shingled course ridges ──────────────────────────────────────────────
// Thin raised lines running across the cap at intervals, suggesting
// overlapping shingle courses. Sit on the top profile edges.
@@ -184,21 +384,27 @@ function addShingledTabs(
positions: number[],
normals: number[],
uvs: number[],
halfLen: number,
top: [number, number][],
startX: number,
endX: number,
top: ProfilePoint[],
h: number,
seatYAt: (x: number, z: number) => number,
endTaper: RidgeVentEndTaper | null = null,
): void {
const totalLen = halfLen * 2
const totalLen = endX - startX
const numTabs = Math.max(2, Math.round(totalLen / SHINGLED_TAB_SIZE))
const tabLen = totalLen / numTabs
const ridgeH = h * 0.06
const ridgeD = Math.min(0.01, tabLen * 0.15)
for (let tab = 1; tab < numTabs; tab++) {
const x = -halfLen + tab * tabLen
const x = startX + tab * tabLen
if (isInsideEndTaper(x, startX, endX, endTaper)) continue
for (let i = 0; i < top.length - 1; i++) {
const [z0, y0] = top[i]!
const [z1, y1] = top[i + 1]!
const [z0, capY0] = top[i]!
const [z1, capY1] = top[i + 1]!
const y0 = seatYAt(x, z0) + capY0
const y1 = seatYAt(x, z1) + capY1
const dz = z1 - z0
const dy = y1 - y0
const len = Math.sqrt(dz * dz + dy * dy) || 1
@@ -208,6 +414,11 @@ function addShingledTabs(
const r0z = z0 + nz * ridgeH
const r1y = y1 + ny * ridgeH
const r1z = z1 + nz * ridgeH
const backX = x - ridgeD
const by0 = seatYAt(backX, z0) + capY0
const by1 = seatYAt(backX, z1) + capY1
const br0y = by0 + ny * ridgeH
const br1y = by1 + ny * ridgeH
pushQuad(
positions,
normals,
@@ -222,16 +433,29 @@ function addShingledTabs(
positions,
normals,
uvs,
[x - ridgeD, r0y, r0z],
[x - ridgeD, r1y, r1z],
[x - ridgeD, y1, z1],
[x - ridgeD, y0, z0],
[backX, br0y, r0z],
[backX, br1y, r1z],
[backX, by1, z1],
[backX, by0, z0],
[-1, 0, 0],
)
}
}
}
function isInsideEndTaper(
x: number,
startX: number,
endX: number,
endTaper: RidgeVentEndTaper | null,
): boolean {
if (!endTaper) return false
return (
(endTaper.taperAtStart && x <= startX + endTaper.taperLength) ||
(endTaper.taperAtEnd && x >= endX - endTaper.taperLength)
)
}
// ─── Geometry plumbing ───────────────────────────────────────────────────
function buildBufferGeometry(
@@ -240,6 +464,13 @@ function buildBufferGeometry(
uvs: number[],
): THREE.BufferGeometry {
const geo = new THREE.BufferGeometry()
if (positions.length === 0) {
geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
geo.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
geo.computeBoundingSphere()
return geo
}
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
@@ -247,6 +478,274 @@ function buildBufferGeometry(
return geo
}
function clipRidgeVentGeometryToSegmentTrim(
geometry: THREE.BufferGeometry,
node: RidgeVentNode,
segment: RoofSegmentNode,
): THREE.BufferGeometry {
const planes = getSegmentTrimClipPlanes(segment)
if (planes.length === 0) return geometry
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
const uv = geometry.getAttribute('uv')
if (!position || !normal || !uv) return geometry
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
for (let i = 0; i < position.count; i += 3) {
let polygon: RidgeVentGeometryVertex[] = [
readGeometryVertex(position, normal, uv, i),
readGeometryVertex(position, normal, uv, i + 1),
readGeometryVertex(position, normal, uv, i + 2),
]
for (const plane of planes) {
polygon = clipPolygonToSegmentTrimPlane(polygon, plane, node)
if (polygon.length < 3) break
}
if (polygon.length < 3) continue
for (let j = 1; j < polygon.length - 1; j += 1) {
pushGeometryVertex(positions, normals, uvs, polygon[0]!)
pushGeometryVertex(positions, normals, uvs, polygon[j]!)
pushGeometryVertex(positions, normals, uvs, polygon[j + 1]!)
}
}
return buildBufferGeometry(positions, normals, uvs)
}
function getSupportLineForVent(
segment: RoofSegmentNode,
node: RidgeVentNode,
): RidgeVentSupportLine | null {
const lines = getRidgeVentLinesForSegment(segment)
if (lines.length === 0) return null
const centerX = finiteNumber(node.position?.[0], 0)
const centerZ = finiteNumber(node.position?.[2], 0)
const rotationY = finiteNumber(node.rotation, 0)
const dirX = Math.cos(rotationY)
const dirZ = -Math.sin(rotationY)
let best: RidgeVentSupportLine | null = null
let bestScore = Number.POSITIVE_INFINITY
for (const line of lines) {
const [sx, sz] = line.start
const [ex, ez] = line.end
const lineDx = ex - sx
const lineDz = ez - sz
const lineLength = Math.hypot(lineDx, lineDz)
if (!(lineLength > 1e-4)) continue
const unitX = lineDx / lineLength
const unitZ = lineDz / lineLength
const yawPenalty = 1 - Math.abs(unitX * dirX + unitZ * dirZ)
const centerOffsetX = centerX - sx
const centerOffsetZ = centerZ - sz
const t = Math.max(0, Math.min(lineLength, centerOffsetX * unitX + centerOffsetZ * unitZ))
const nearestX = sx + unitX * t
const nearestZ = sz + unitZ * t
const distanceSq = (centerX - nearestX) ** 2 + (centerZ - nearestZ) ** 2
const score = distanceSq + yawPenalty * 6
if (score < bestScore) {
bestScore = score
const startLocalX = (sx - centerX) * dirX + (sz - centerZ) * dirZ
const endLocalX = (ex - centerX) * dirX + (ez - centerZ) * dirZ
const startRadiusSq = sx * sx + sz * sz
const endRadiusSq = ex * ex + ez * ez
const outerIsStart = startRadiusSq > endRadiusSq
const minIsStart = startLocalX <= endLocalX
best = {
startX: Math.min(startLocalX, endLocalX),
endX: Math.max(startLocalX, endLocalX),
name: line.name,
taperAtStart: minIsStart ? outerIsStart : !outerIsStart,
taperAtEnd: minIsStart ? !outerIsStart : outerIsStart,
}
}
}
return best
}
function readGeometryVertex(
position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
normal: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
uv: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
index: number,
): RidgeVentGeometryVertex {
return {
x: position.getX(index),
y: position.getY(index),
z: position.getZ(index),
nx: normal.getX(index),
ny: normal.getY(index),
nz: normal.getZ(index),
u: uv.getX(index),
v: uv.getY(index),
}
}
function pushGeometryVertex(
positions: number[],
normals: number[],
uvs: number[],
vertex: RidgeVentGeometryVertex,
) {
positions.push(vertex.x, vertex.y, vertex.z)
normals.push(vertex.nx, vertex.ny, vertex.nz)
uvs.push(vertex.u, vertex.v)
}
function clipPolygonToSegmentTrimPlane(
polygon: RidgeVentGeometryVertex[],
plane: SegmentTrimClipPlane,
node: RidgeVentNode,
): RidgeVentGeometryVertex[] {
const next: RidgeVentGeometryVertex[] = []
let previous = polygon[polygon.length - 1]!
let previousDistance = getTrimClipDistance(previous, plane, node)
let previousInside = previousDistance <= 1e-6
for (const current of polygon) {
const currentDistance = getTrimClipDistance(current, plane, node)
const currentInside = currentDistance <= 1e-6
if (currentInside) {
if (!previousInside) {
next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance))
}
next.push(current)
} else if (previousInside) {
next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance))
}
previous = current
previousDistance = currentDistance
previousInside = currentInside
}
return next
}
function getTrimClipDistance(
vertex: RidgeVentGeometryVertex,
plane: SegmentTrimClipPlane,
node: RidgeVentNode,
): number {
const centerX = finiteNumber(node.position?.[0], 0)
const centerZ = finiteNumber(node.position?.[2], 0)
const rotationY = finiteNumber(node.rotation, 0)
const segmentX = centerX + vertex.x * Math.cos(rotationY) + vertex.z * Math.sin(rotationY)
const segmentZ = centerZ - vertex.x * Math.sin(rotationY) + vertex.z * Math.cos(rotationY)
return plane.signedDistance(segmentX, segmentZ)
}
function interpolateGeometryVertex(
a: RidgeVentGeometryVertex,
b: RidgeVentGeometryVertex,
distanceA: number,
distanceB: number,
): RidgeVentGeometryVertex {
const t = distanceA / (distanceA - distanceB || 1)
return {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
z: lerp(a.z, b.z, t),
nx: lerp(a.nx, b.nx, t),
ny: lerp(a.ny, b.ny, t),
nz: lerp(a.nz, b.nz, t),
u: lerp(a.u, b.u, t),
v: lerp(a.v, b.v, t),
}
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function getSegmentTrimClipPlanes(segment: RoofSegmentNode): SegmentTrimClipPlane[] {
const trim = normalizeRoofSegmentTrim(segment)
const planes: SegmentTrimClipPlane[] = []
const leftX = -segment.width / 2 + trim.left
const rightX = segment.width / 2 - trim.right
const frontZ = segment.depth / 2 - trim.front
const backZ = -segment.depth / 2 + trim.back
if (trim.left > 0) planes.push({ signedDistance: (x) => leftX - x })
if (trim.right > 0) planes.push({ signedDistance: (x) => x - rightX })
if (trim.front > 0) planes.push({ signedDistance: (_x, z) => z - frontZ })
if (trim.back > 0) planes.push({ signedDistance: (_x, z) => backZ - z })
const diagonalPlane = (
lineA: readonly [number, number],
lineB: readonly [number, number],
outsidePoint: readonly [number, number],
): SegmentTrimClipPlane | null => {
const dx = lineB[0] - lineA[0]
const dz = lineB[1] - lineA[1]
const length = Math.hypot(dx, dz)
if (!(length > 0)) return null
let nx = -dz / length
let nz = dx / length
const midX = (lineA[0] + lineB[0]) / 2
const midZ = (lineA[1] + lineB[1]) / 2
if (nx * (outsidePoint[0] - midX) + nz * (outsidePoint[1] - midZ) < 0) {
nx *= -1
nz *= -1
}
return {
signedDistance: (x, z) => nx * (x - midX) + nz * (z - midZ),
}
}
const pushDiagonalPlane = (
lineA: readonly [number, number],
lineB: readonly [number, number],
outsidePoint: readonly [number, number],
) => {
const plane = diagonalPlane(lineA, lineB, outsidePoint)
if (plane) planes.push(plane)
}
if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) {
pushDiagonalPlane(
[leftX + trim.frontLeftX, frontZ],
[leftX, frontZ - trim.frontLeftZ],
[leftX - 1, frontZ + 1],
)
}
if (trim.frontRightX > 0 && trim.frontRightZ > 0) {
pushDiagonalPlane(
[rightX, frontZ - trim.frontRightZ],
[rightX - trim.frontRightX, frontZ],
[rightX + 1, frontZ + 1],
)
}
if (trim.backLeftX > 0 && trim.backLeftZ > 0) {
pushDiagonalPlane(
[leftX, backZ + trim.backLeftZ],
[leftX + trim.backLeftX, backZ],
[leftX - 1, backZ - 1],
)
}
if (trim.backRightX > 0 && trim.backRightZ > 0) {
pushDiagonalPlane(
[rightX - trim.backRightX, backZ],
[rightX, backZ + trim.backRightZ],
[rightX + 1, backZ - 1],
)
}
return planes
}
// Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so
// the shared flat normal points toward `hint`. UVs are dimension-based so
// painted presets tile at world scale across the ridge length and the cap.
+14 -8
View File
@@ -23,7 +23,8 @@ import {
roofSegmentLocalToBuildingLocal,
snapRelativeRoofDragTarget,
} from '../shared/relative-roof-drag'
import { getSurfaceY } from '../shared/roof-surface'
import { resolveRidgeSnap } from '../shared/ridge-snap'
import { getRoofTopSurfaceY } from '../shared/roof-surface'
import {
clearRoofSurfacePlacementGuides,
publishRoofSurfaceNodePlacementGuides,
@@ -32,7 +33,8 @@ import RidgeVentPreview from './preview'
type RidgeVentDragTarget = Pick<RelativeRoofDragTarget, 'segment' | 'localX'> & {
localY: number
localZ: 0
localZ: number
rotation: number
}
/**
@@ -85,11 +87,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
const rawTarget = roofDrag.resolve(event)
if (!rawTarget) return null
const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true)
const snap = resolveRidgeSnap(target.segment, target.localX, target.localZ)
if (!snap) return null
const yOffset = original.position[1] ?? 0
return {
segment: target.segment,
localX: target.localX,
localY: getSurfaceY(target.localX, 0, target.segment),
localZ: 0,
localX: snap.localX,
localY: getRoofTopSurfaceY(snap.localX, snap.localZ, target.segment) + yOffset,
localZ: snap.localZ,
rotation: snap.rotation,
}
}
@@ -111,7 +117,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
lastSnap = [sx, sz]
}
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0) + target.rotation)
setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
target.localX,
@@ -158,8 +164,8 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId,
parentId: targetSegmentId,
position: [target.localX, target.localY, target.localZ],
rotation: original.rotation,
position: [target.localX, original.position[1] ?? 0, target.localZ],
rotation: target.rotation,
visible: true,
metadata: {},
})
+143 -32
View File
@@ -2,7 +2,10 @@
import {
type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial,
type RidgeVentNode,
type RoofNode,
type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry,
@@ -13,25 +16,54 @@ import {
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
getRoofMaterialArray,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap'
import { getSurfaceY } from '../shared/roof-surface'
import { getRoofTopSurfaceY } from '../shared/roof-surface'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildRidgeVentGeometry } from './geometry'
// Single white fallback for every style. Paint customisation comes from
// `node.material` / `node.materialPreset` (default: `preset-white`); the
// fallback only fires for legacy nodes that pre-date the schema default
// and shouldn't punish them with style-specific grey/metal that diverges
// from the "default white" the inspector advertises.
const defaultMaterial = new THREE.MeshStandardMaterial({
color: 0xff_ff_ff,
roughness: 0.85,
metalness: 0.1,
})
function ridgeVentSegmentGeometryKey(segment: RoofSegmentNode | undefined): string {
if (!segment) return 'none'
const trim = segment.trim
return [
segment.roofType,
segment.width,
segment.depth,
segment.wallHeight,
segment.pitch,
segment.wallThickness,
segment.deckThickness,
segment.overhang,
segment.shingleThickness,
segment.gambrelLowerWidthRatio,
segment.gambrelLowerHeightRatio,
segment.mansardSteepWidthRatio,
segment.mansardSteepHeightRatio,
segment.dutchHipWidthRatio,
segment.dutchHipHeightRatio,
trim.left,
trim.right,
trim.front,
trim.back,
trim.frontLeft,
trim.frontRight,
trim.backLeft,
trim.backRight,
trim.frontLeftX,
trim.frontLeftZ,
trim.frontRightX,
trim.frontRightZ,
trim.backLeftX,
trim.backLeftZ,
trim.backRightX,
trim.backRightZ,
].join('|')
}
/**
* Ridge vent renderer. Sits along the ridge of a roof-segment — no
@@ -66,6 +98,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
const node: RidgeVentNode = overrides
? ({ ...storeNode, ...overrides } as RidgeVentNode)
: storeNode
const nodePosition = node.position ?? [0, 0, 0]
const segmentStore = useScene((state) =>
node.roofSegmentId
@@ -87,11 +120,44 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
? ({ ...segmentStore, ...segmentOverrides } as RoofSegmentNode)
: segmentStore
: undefined
const parentRoof = useScene((state) =>
segmentStore?.parentId
? (state.nodes[segmentStore.parentId as AnyNodeId] as RoofNode | undefined)
: undefined,
)
const segmentGeometryKey = ridgeVentSegmentGeometryKey(segment)
const rotationY = node.rotation ?? 0
const snap = useMemo(
() =>
segment && Math.abs(rotationY) < 1e-5
? resolveRidgeSnap(segment, nodePosition[0] ?? 0, nodePosition[2] ?? 0)
: null,
[segment, rotationY, nodePosition[0], nodePosition[2]],
)
const ridgeX = snap ? snap.localX : (nodePosition[0] ?? 0)
const ridgeZ = snap ? snap.localZ : (nodePosition[2] ?? 0)
const effectiveNode = useMemo<RidgeVentNode>(
() => ({
...node,
position: [ridgeX, nodePosition[1] ?? 0, ridgeZ],
}),
[node, ridgeX, ridgeZ, nodePosition[1]],
)
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: `segmentGeometryKey` captures the segment fields that affect the generated mesh; depending on the whole segment would rebuild on unrelated node changes.
const geometry = useMemo(
() => buildRidgeVentGeometry(node),
[node.length, node.width, node.height, node.style, node.endCaps],
() => buildRidgeVentGeometry(effectiveNode, segment),
[
effectiveNode.length,
effectiveNode.width,
effectiveNode.height,
effectiveNode.style,
effectiveNode.endCaps,
effectiveNode.rotation,
effectiveNode.position[0],
effectiveNode.position[2],
segmentGeometryKey,
],
)
useEffect(() => () => geometry.dispose(), [geometry])
@@ -104,13 +170,62 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
// closed solid in `geometry.ts` is the right fix if the underside-view
// becomes noticeable.
const material = useMemo(() => {
if (!textures || (!node.material && !node.materialPreset)) {
return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
const createDefaultTopMaterial = () => {
const parentSpec = parentRoof ? getEffectiveRoofSurfaceMaterial(parentRoof, 'top') : undefined
const spec = segment ? getEffectiveSegmentSurfaceMaterial(segment, 'top', parentSpec) : null
if (typeof spec?.materialPreset === 'string') {
const resolved = createMaterialFromPresetRef(spec.materialPreset, shading)
if (resolved) return resolved
}
if (spec?.material !== undefined) {
return createMaterial(spec.material, shading)
}
const roofMaterials = parentRoof
? getRoofMaterialArray(parentRoof, shading, textures, colorPreset, sceneTheme)
: null
return (
roofMaterials?.[3] ??
createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
)
}
return node.material
? createMaterial(node.material, shading)
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
if (node.material) {
return createMaterial(node.material, shading)
}
if (node.materialPreset) {
return createMaterialFromPresetRef(node.materialPreset, shading) ?? createDefaultTopMaterial()
}
return createDefaultTopMaterial()
}, [
textures,
colorPreset,
sceneTheme,
shading,
node.material,
node.materialPreset,
segment,
parentRoof,
])
// Map vent-local geometry into the host segment's local frame (where the trim
// cut prisms live). Recompose the same pose the inner mesh group is mounted
// with — ridge snap for X/Z, slope-locked base Y + offset, yaw — so the clip
// matches the rendered placement. Computed before the early return so the
// hook order stays stable.
const localToSegment = useMemo(() => {
if (!segment) return new THREE.Matrix4()
const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment)
const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0))
const ridgeY = baseY + RIDGE_LIFT + yOffset
return new THREE.Matrix4().compose(
new THREE.Vector3(ridgeX, ridgeY, ridgeZ),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotationY),
new THREE.Vector3(1, 1, 1),
)
}, [segment, ridgeX, ridgeZ, rotationY, nodePosition[1]])
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
if (!segment) return null
@@ -126,33 +241,29 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
const segPos = segment.position ?? [0, 0, 0]
const segRotY = segment.rotation ?? 0
// Lock the BASE position to the ridge so the vent always starts on the
// slope top; treat `position[1]` and `position[2]` as user-tunable OFFSETS
// off that base (Y above ridge lift, Z away from ridge centerline). So
// Lock the BASE position to the rendered roof skin so the vent always starts
// on the roof structure; treat `position[1]` and `position[2]` as user-tunable OFFSETS
// off that base (Y above the surface, Z away from ridge centerline). So
// after placement the inspector's Y / Z sliders nudge the vent off the
// locked ridge without losing the slope-tracking base. X is the position
// along the ridge — the snap re-clamps it to the segment's ridge span.
const snap = resolveRidgeSnap(segment, node.position[0] ?? 0, 0)
const ridgeX = snap ? snap.localX : (node.position[0] ?? 0)
const baseZ = snap ? snap.localZ : 0
const baseY = getSurfaceY(ridgeX, baseZ, segment) + RIDGE_LIFT
const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment)
// Clamp legacy stored Y (absolute peak height from earlier versions) so the
// vent doesn't fly off when the field was an absolute Y instead of offset.
const yOffset = Math.max(-2, Math.min(2, node.position[1] ?? 0))
const ridgeY = baseY + yOffset
const ridgeZ = baseZ + (node.position[2] ?? 0)
const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0))
const ridgeY = baseY + RIDGE_LIFT + yOffset
return (
<group position={segPos} rotation-y={segRotY}>
<group
position={[ridgeX, ridgeY, ridgeZ]}
ref={ref}
rotation-y={node.rotation ?? 0}
rotation-y={rotationY}
visible={node.visible}
>
<mesh
castShadow
geometry={geometry}
geometry={clippedGeometry ?? geometry}
material={material}
name="ridge-vent-surface"
receiveShadow
+19 -12
View File
@@ -16,6 +16,7 @@ import * as THREE from 'three'
import { resolveRidgeSnap } from '../shared/ridge-snap'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getRoofTopSurfaceY } from '../shared/roof-surface'
import {
clearRoofSurfacePlacementGuides,
publishRoofSurfacePlacementGuides,
@@ -27,11 +28,9 @@ import RidgeVentPreview from './preview'
const worldPoint = new THREE.Vector3()
/**
* Ridge vent placement tool. The cursor preview snaps to the ridge
* (Z=0 in segment-local space) of whichever segment is under the
* cursor, since the ridge vent's whole purpose is to sit on the peak.
* Click anywhere on a segment commits the vent at the ridge directly
* above that hit (X stays where the cursor was, Z snaps to 0).
* Ridge vent placement tool. The cursor preview snaps to the nearest
* ridge/break line of whichever segment is under the cursor, since the
* cap needs to straddle a real roof crease.
*/
const RidgeVentTool = () => {
const activeBuildingId = useViewer((s) => s.selection.buildingId)
@@ -72,9 +71,9 @@ const RidgeVentTool = () => {
)
if (!hit) return
// Project the cursor onto the segment's ridge line (clamped to the
// segment's ridge span). The preview then moves ALONG the ridge as the
// cursor moves — never off it. Flat segments have no ridge: hide.
// Project the cursor onto the nearest segment ridge/break line.
// The preview then moves along that line as the cursor moves — never
// off it. Flat segments have no ridge: hide.
const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
if (!snap) {
setPreviewPos(null)
@@ -84,7 +83,11 @@ const RidgeVentTool = () => {
const segObj = sceneRegistry.nodes.get(hit.segment.id)
let ridgeWorld: [number, number, number]
if (segObj) {
const ridgeLocal = new THREE.Vector3(snap.localX, hit.localY, snap.localZ)
const ridgeLocal = new THREE.Vector3(
snap.localX,
getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment),
snap.localZ,
)
segObj.updateWorldMatrix(true, false)
ridgeLocal.applyMatrix4(segObj.matrixWorld)
ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z]
@@ -100,12 +103,16 @@ const RidgeVentTool = () => {
lastSnapRef.current = [sx, sz]
}
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation)
setPreviewPos(worldToBuildingLocal(ridgeWorld[0], ridgeWorld[1], ridgeWorld[2]))
publishRoofSurfacePlacementGuides({
roof: event.node as RoofNode,
segment: hit.segment,
center: [snap.localX, hit.localY, snap.localZ],
center: [
snap.localX,
getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment),
snap.localZ,
],
footprint: roofSurfaceFootprintFromNode(previewNode),
mode: 'linear-edge',
})
@@ -129,7 +136,7 @@ const RidgeVentTool = () => {
name: 'Ridge Vent',
roofSegmentId: hit.segment.id,
position: [snap.localX, 0, snap.localZ],
rotation: 0,
rotation: snap.rotation,
})
state.createNode(vent, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
@@ -0,0 +1,139 @@
import { describe, expect, test } from 'bun:test'
import type { RoofSegmentNode } from '@pascal-app/core'
import { getRoofSegmentPlanLinework } from './floorplan'
function dutchSegment(overrides: Partial<RoofSegmentNode> = {}): RoofSegmentNode {
return {
object: 'node',
id: 'rseg_test',
type: 'roof-segment',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
roofType: 'dutch',
width: 8,
depth: 6,
wallHeight: 2.5,
pitch: 40,
wallThickness: 0.1,
deckThickness: 0.1,
overhang: 0.3,
shingleThickness: 0.05,
gambrelLowerWidthRatio: 0.5,
gambrelLowerHeightRatio: 0.6,
mansardSteepWidthRatio: 0.15,
mansardSteepHeightRatio: 0.7,
dutchHipWidthRatio: 0.25,
dutchHipHeightRatio: 0.5,
dutchWaistLengthRatio: 1,
children: [],
...overrides,
} as RoofSegmentNode
}
describe('getRoofSegmentPlanLinework', () => {
test('draws a dutch width-axis upper ridge plus waist linework', () => {
const linework = getRoofSegmentPlanLinework(dutchSegment())
expect(linework.ridges).toEqual([
[
[-2.5, 0],
[2.5, 0],
],
])
expect(linework.breaks).toEqual([
[
[-2.5, 1.5],
[2.5, 1.5],
],
[
[2.5, 1.5],
[2.5, -1.5],
],
[
[2.5, -1.5],
[-2.5, -1.5],
],
[
[-2.5, -1.5],
[-2.5, 1.5],
],
])
expect(linework.hips).toContainEqual([
[-4, 3],
[-2.5, 1.5],
])
expect(linework.hips).toContainEqual([
[4, 3],
[2.5, 1.5],
])
})
test('draws a dutch depth-axis upper ridge when the depth exceeds the width', () => {
const linework = getRoofSegmentPlanLinework(dutchSegment({ width: 6, depth: 8 }))
expect(linework.ridges).toEqual([
[
[0, 2.5],
[0, -2.5],
],
])
expect(linework.breaks).toEqual([
[
[-1.5, 2.5],
[1.5, 2.5],
],
[
[1.5, 2.5],
[1.5, -2.5],
],
[
[1.5, -2.5],
[-1.5, -2.5],
],
[
[-1.5, -2.5],
[-1.5, 2.5],
],
])
expect(linework.hips).toContainEqual([
[-3, 4],
[-1.5, 2.5],
])
expect(linework.hips).toContainEqual([
[3, -4],
[1.5, -2.5],
])
})
test('shortens dutch waist length along the ridge axis', () => {
const linework = getRoofSegmentPlanLinework(dutchSegment({ dutchWaistLengthRatio: 0.5 }))
expect(linework.ridges).toEqual([
[
[-1.25, 0],
[1.25, 0],
],
])
expect(linework.breaks).toEqual([
[
[-1.25, 1.5],
[1.25, 1.5],
],
[
[1.25, 1.5],
[1.25, -1.5],
],
[
[1.25, -1.5],
[-1.25, -1.5],
],
[
[-1.25, -1.5],
[-1.25, 1.5],
],
])
})
})
+19 -29
View File
@@ -1,9 +1,10 @@
import type {
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
RoofNode,
RoofSegmentNode,
import {
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
getDutchRoofMetrics,
type RoofNode,
type RoofSegmentNode,
} from '@pascal-app/core'
/**
@@ -23,7 +24,7 @@ export function buildRoofSegmentFloorplan(
ctx: GeometryContext,
): FloorplanGeometry | null {
const roof = ctx.parent as RoofNode | null
if (!roof || roof.type !== 'roof') return null
if (roof?.type !== 'roof') return null
// Segment center in world coords. Floor-plan plots at `-rotation` so
// SVG's CW-with-y-down `rotate` direction ends up matching Three.js
@@ -285,30 +286,19 @@ export function getRoofSegmentPlanLinework(node: RoofSegmentNode): {
break
}
case 'dutch': {
// Hipped lower skirt (eave corners → waist corners) + the gablet
// fold, then a gable-style ridge on top of the waist.
const i = Math.min(node.width, node.depth) * node.dutchHipWidthRatio
if (hw - i > 0.02 && hd - i > 0.02) {
const w1: PlanPt = [-hw + i, hd - i]
const w2: PlanPt = [hw - i, hd - i]
const w3: PlanPt = [hw - i, -hd + i]
const w4: PlanPt = [-hw + i, -hd + i]
hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
if (node.width >= node.depth) {
const r1: PlanPt = [-hw + i, 0]
const r2: PlanPt = [hw - i, 0]
ridges.push([r1, r2])
hips.push([w1, r1], [w4, r1], [w2, r2], [w3, r2])
} else {
const r1: PlanPt = [0, hd - i]
const r2: PlanPt = [0, -hd + i]
ridges.push([r1, r2])
hips.push([w1, r1], [w2, r1], [w3, r2], [w4, r2])
}
} else {
const metrics = getDutchRoofMetrics(node)
if (!(metrics.waistHalfX > 0.02 && metrics.waistHalfZ > 0.02)) {
pushHip()
break
}
const w1: PlanPt = [-metrics.waistHalfX, metrics.waistHalfZ]
const w2: PlanPt = [metrics.waistHalfX, metrics.waistHalfZ]
const w3: PlanPt = [metrics.waistHalfX, -metrics.waistHalfZ]
const w4: PlanPt = [-metrics.waistHalfX, -metrics.waistHalfZ]
hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
ridges.push([metrics.ridgeStart, metrics.ridgeEnd])
break
}
}
+155 -6
View File
@@ -3,6 +3,10 @@
import {
type AnyNode,
type AnyNodeId,
createDefaultRidgeVentsForSegment,
isAutoRidgeVentEnabled,
isDefaultRidgeVentNode,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofType,
@@ -15,6 +19,7 @@ import {
PanelWrapper,
SegmentedControl,
SliderControl,
ToggleControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
@@ -26,10 +31,10 @@ const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [
{ label: 'Hip', value: 'hip' },
{ label: 'Gable', value: 'gable' },
{ label: 'Shed', value: 'shed' },
{ label: 'Flat', value: 'flat' },
]
const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
{ label: 'Flat', value: 'flat' },
{ label: 'Gambrel', value: 'gambrel' },
{ label: 'Dutch', value: 'dutch' },
{ label: 'Mansard', value: 'mansard' },
@@ -44,15 +49,34 @@ const PITCH_PRESETS: { label: string; deg: number }[] = [
{ label: '12/12', deg: 45 },
]
function shouldShowTrimPlanes(metadata: unknown): boolean {
return metadataRecord(metadata).showTrimPlanes === true
}
function metadataRecord(metadata: unknown): Record<string, unknown> {
if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) {
return metadata as Record<string, unknown>
}
return {}
}
export default function RoofSegmentPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined,
)
const autoRidgeVentEnabled = useScene((s) => {
const current = selectedId
? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined)
: undefined
if (current?.type !== 'roof-segment') return false
return isAutoRidgeVentEnabled(current, s.nodes)
})
const handleUpdate = useCallback(
(updates: Partial<RoofSegmentNode>) => {
@@ -62,15 +86,37 @@ export default function RoofSegmentPanel() {
[selectedId, updateNode],
)
const handleRoofTypeChange = useCallback(
(roofType: RoofType) => {
// Switching to Dutch resets the shape parameters to their defaults so the
// gablet is well-formed regardless of the leftover values from the
// previous roof type.
handleUpdate(
roofType === 'dutch'
? {
roofType,
dutchHipWidthRatio: ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
dutchHipHeightRatio: ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
dutchWaistLengthRatio: ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
dutchGabletRake: ROOF_SHAPE_DEFAULTS.dutchGabletRake,
dutchTopRakeThickness: ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness,
}
: { roofType },
)
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleBack = useCallback(() => {
if (node?.parentId) {
setRoofHostDragArmedId(node.parentId as AnyNodeId)
setSelection({ selectedIds: [node.parentId] })
}
}, [node?.parentId, setSelection])
}, [node?.parentId, setRoofHostDragArmedId, setSelection])
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
@@ -117,8 +163,52 @@ export default function RoofSegmentPanel() {
}
}, [selectedId, node, setSelection])
const handleAutoRidgeVentToggle = useCallback(
(checked: boolean) => {
if (!selectedId) return
const scene = useScene.getState()
const current = scene.nodes[selectedId as AnyNodeId] as RoofSegmentNode | undefined
if (current?.type !== 'roof-segment') return
scene.updateNode(selectedId as AnyNodeId, {
metadata: { ...metadataRecord(current.metadata), autoRidgeVent: checked },
})
const latest = useScene.getState().nodes[selectedId as AnyNodeId] as
| RoofSegmentNode
| undefined
if (latest?.type !== 'roof-segment') return
const defaultVentIds = (latest.children ?? []).filter((childId) =>
isDefaultRidgeVentNode(useScene.getState().nodes[childId as AnyNodeId], latest.id),
) as AnyNodeId[]
if (!checked) {
if (defaultVentIds.length > 0) {
useScene.getState().deleteNodes(defaultVentIds)
}
return
}
if (defaultVentIds.length > 0) return
const ridgeVents = createDefaultRidgeVentsForSegment(latest)
if (ridgeVents.length === 0) return
scene.createNodes(
ridgeVents.map((ridgeVent) => ({
node: ridgeVent,
parentId: latest.id as AnyNodeId,
})),
)
},
[selectedId],
)
if (!(node && node.type === 'roof-segment' && selectedId)) return null
const showTrimPlanes = shouldShowTrimPlanes(node.metadata)
return (
<PanelWrapper
icon="/icons/roof.webp"
@@ -129,17 +219,36 @@ export default function RoofSegmentPanel() {
>
<PanelSection title="Roof Type">
<SegmentedControl
onChange={(v) => handleUpdate({ roofType: v })}
onChange={(v) => handleRoofTypeChange(v)}
options={ROOF_TYPE_OPTIONS}
value={node.roofType}
/>
<SegmentedControl
onChange={(v) => handleUpdate({ roofType: v })}
onChange={(v) => handleRoofTypeChange(v)}
options={ROOF_TYPE_OPTIONS_2}
value={node.roofType}
/>
</PanelSection>
<PanelSection title="Trim">
<ToggleControl
checked={showTrimPlanes}
label="Show trim planes"
onChange={(checked) =>
handleUpdate({
metadata: { ...metadataRecord(node.metadata), showTrimPlanes: checked },
})
}
/>
{node.roofType !== 'shed' && node.roofType !== 'flat' && (
<ToggleControl
checked={autoRidgeVentEnabled}
label="Auto ridge vent"
onChange={handleAutoRidgeVentToggle}
/>
)}
</PanelSection>
<PanelSection title="Footprint">
<SliderControl
label="Width"
@@ -251,7 +360,7 @@ export default function RoofSegmentPanel() {
{node.roofType === 'dutch' && (
<PanelSection title="Shape">
<SliderControl
label="Hip Width"
label="Waist Width"
max={0.45}
min={0.05}
onChange={(v) => handleUpdate({ dutchHipWidthRatio: v })}
@@ -261,7 +370,7 @@ export default function RoofSegmentPanel() {
value={Math.round(node.dutchHipWidthRatio * 100) / 100}
/>
<SliderControl
label="Hip Height"
label="Waist Height"
max={0.9}
min={0.1}
onChange={(v) => handleUpdate({ dutchHipHeightRatio: v })}
@@ -270,6 +379,46 @@ export default function RoofSegmentPanel() {
unit=""
value={Math.round(node.dutchHipHeightRatio * 100) / 100}
/>
<SliderControl
label="Waist Length"
max={1}
min={0.1}
onChange={(v) => handleUpdate({ dutchWaistLengthRatio: v })}
precision={2}
step={0.01}
unit=""
value={
Math.round(
(node.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio) * 100,
) / 100
}
/>
<SliderControl
label="Top Rake Thick."
max={0.5}
min={0.01}
onChange={(v) => handleUpdate({ dutchTopRakeThickness: v })}
precision={2}
step={0.01}
unit="m"
value={
Math.round(
(node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness) * 100,
) / 100
}
/>
<SliderControl
label="Top Rake Length"
max={3}
min={0}
onChange={(v) => handleUpdate({ dutchGabletRake: v })}
precision={2}
step={0.01}
unit="m"
value={
Math.round((node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake) * 100) / 100
}
/>
</PanelSection>
)}
+11 -3
View File
@@ -5,6 +5,7 @@ import {
type AnyNodeId,
type BoxVentNode,
type ChimneyNode,
createDefaultRidgeVentsForSegment,
type DormerNode,
type GutterNode,
type RidgeVentNode,
@@ -37,7 +38,7 @@ export default function RoofPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
@@ -176,8 +177,15 @@ export default function RoofPanel() {
roofType: 'gable',
position: [2, 0, 2],
})
createNode(segment, node.id as AnyNodeId)
}, [node, createNode])
const ridgeVents = createDefaultRidgeVentsForSegment(segment)
createNodes([
{ node: segment, parentId: node.id as AnyNodeId },
...ridgeVents.map((ridgeVent) => ({
node: ridgeVent,
parentId: segment.id as AnyNodeId,
})),
])
}, [node, createNodes])
const handleSelectSegment = useCallback(
(segmentId: string) => {
+69 -26
View File
@@ -13,6 +13,7 @@ import {
type StairNode,
type StairSegmentNode,
sceneRegistry,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
type WallNode,
@@ -40,6 +41,38 @@ import * as THREE from 'three'
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function disableRaycastDuringDrag(root: THREE.Object3D | undefined): () => void {
if (!root) return () => {}
const originals: Array<[THREE.Object3D, THREE.Object3D['raycast']]> = []
root.traverse((child) => {
originals.push([child, child.raycast])
child.raycast = () => {}
})
return () => {
for (const [child, raycast] of originals) {
child.raycast = raycast
}
}
}
function resolvePreviewRotationY(
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode,
localRotation: number,
): number {
if ((node.type === 'roof-segment' || node.type === 'stair-segment') && node.parentId) {
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
const parentRotation =
parentNode && 'rotation' in parentNode && typeof parentNode.rotation === 'number'
? parentNode.rotation
: 0
return parentRotation + localRotation
}
return localRotation
}
export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
}> = ({ node: movingNode }) => {
@@ -59,7 +92,9 @@ export const MoveRoofTool: React.FC<{
const previousGridPosRef = useRef<[number, number] | null>(null)
const dragAnchorRef = useRef<[number, number] | null>(null)
const [previewRotation, setPreviewRotation] = useState<number>(movingNode.rotation as number)
const [previewRotation, setPreviewRotation] = useState<number>(() =>
resolvePreviewRotationY(movingNode, movingNode.rotation as number),
)
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(movingNode.id)
if (obj) {
@@ -124,24 +159,19 @@ export const MoveRoofTool: React.FC<{
movingNode.position[1],
movingNode.position[2],
]
const movingObject = sceneRegistry.nodes.get(movingNode.id)
const restoreRaycasts = disableRaycastDuringDrag(movingObject)
// For roof-segment moves: the selection was cleared before entering move mode,
// so isSelected=false on the parent roof, hiding individual segment meshes and
// showing only the merged mesh. We directly flip Three.js visibility so the
// user sees the individual segment tracking the cursor.
let segmentWrapperGroup: THREE.Object3D | null = null
let mergedRoofMesh: THREE.Object3D | null = null
if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') {
const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
if (segmentMesh?.parent) {
// segmentMesh.parent = <group visible={isSelected}> wrapper in Roof/StairRenderer
// segmentMesh.parent.parent = the registered roof/stair group
segmentWrapperGroup = segmentMesh.parent
const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof'
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null
segmentWrapperGroup.visible = true
if (mergedRoofMesh) mergedRoofMesh.visible = false
}
const syncHostedPreview = (
patch: Pick<RoofSegmentNode | StairSegmentNode, 'position' | 'rotation'>,
) => {
if (movingNode.type !== 'roof-segment' && movingNode.type !== 'stair-segment') return
useLiveNodeOverrides.getState().set(movingNode.id, patch as Record<string, unknown>)
}
const clearHostedPreview = () => {
if (movingNode.type !== 'roof-segment' && movingNode.type !== 'stair-segment') return
useLiveNodeOverrides.getState().clear(movingNode.id)
}
const resolveLevelId = () => {
@@ -360,6 +390,10 @@ export const MoveRoofTool: React.FC<{
position: lastLocalPosition,
rotation: pendingRotation,
})
syncHostedPreview({
position: lastLocalPosition,
rotation: pendingRotation,
})
}
const onGridClick = (event: GridEvent) => {
@@ -393,6 +427,7 @@ export const MoveRoofTool: React.FC<{
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [committedId] })
clearHostedPreview()
useLiveTransforms.getState().clear(movingNode.id)
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode()
@@ -406,6 +441,7 @@ export const MoveRoofTool: React.FC<{
const onCancel = () => {
wasCancelled = true
clearHostedPreview()
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
if (isNew) {
@@ -436,7 +472,7 @@ export const MoveRoofTool: React.FC<{
triggerSFX('sfx:item-rotate')
pendingRotation += rotationDelta
setPreviewRotation(pendingRotation)
setPreviewRotation(resolvePreviewRotationY(movingNode, pendingRotation))
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
@@ -457,6 +493,10 @@ export const MoveRoofTool: React.FC<{
rotation: pendingRotation,
})
}
syncHostedPreview({
position: lastLocalPosition,
rotation: pendingRotation,
})
}
}
@@ -467,11 +507,10 @@ export const MoveRoofTool: React.FC<{
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
// Restore segment wrapper visibility (React will re-sync on next render)
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
if (mergedRoofMesh) mergedRoofMesh.visible = true
restoreRaycasts()
// Clear ephemeral live transform + any alignment guides
clearHostedPreview()
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
@@ -500,10 +539,14 @@ export const MoveRoofTool: React.FC<{
}
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
// Green footprint box during whole-stair / whole-roof moves. Skipped for
// segments — their cursor lives in parent-local space and the bounding box
// would render in the wrong frame.
const showBoundingBox = movingNode.type === 'stair' || movingNode.type === 'roof'
// Show the same green drag box for both top-level roofs/stairs and their
// segments. Segment cursor positions are converted into the tool's
// building-local frame above, so the box can now ride the cursor correctly.
const showBoundingBox =
movingNode.type === 'stair' ||
movingNode.type === 'roof' ||
movingNode.type === 'roof-segment' ||
movingNode.type === 'stair-segment'
return (
<group visible={cursorVisible}>
@@ -33,7 +33,7 @@ export function buildOpeningPlacementDimensions(
ctx: GeometryContext,
): FloorplanGeometry[] {
const wall = ctx.parent as WallNode | null
if (!wall || wall.type !== 'wall') return []
if (wall?.type !== 'wall') return []
if (isCurvedWall(wall)) return []
const [x1, z1] = wall.start
@@ -161,7 +161,7 @@ function computeOutwardNormal(
let count = 0
for (const childId of levelChildren) {
const child = ctx.resolve(childId) as AnyNode | undefined
if (!child || child.type !== 'wall') continue
if (child?.type !== 'wall') continue
const w = child as WallNode
sumX += w.start[0] + w.end[0]
sumZ += w.start[1] + w.end[1]
@@ -158,7 +158,7 @@ export function planPipeRunTranslationOffsets(args: {
}
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'pipe-fitting') return null
if (partner?.type !== 'pipe-fitting') return null
const elbow = {
...(partner as PipeFittingNode),
...pipeElbowProfilePatch(profile),
@@ -568,7 +568,7 @@ function directRiserCollapseAction(args: {
)
if (!farPort) return null
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'pipe-fitting') return null
if (lower?.type !== 'pipe-fitting') return null
const lowerElbow = { ...(lower as PipeFittingNode), ...profilePatch } as PipeFittingNode
if (lowerElbow.fittingType !== 'elbow') return null
@@ -665,7 +665,7 @@ function fittingRiserAction(args: {
continue
}
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'pipe-fitting') {
if (lower?.type !== 'pipe-fitting') {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
@@ -806,7 +806,7 @@ function planVerticalOffsetsAtDy(
// shows a red preview rather than dragging the network up to absorb it).
if (conn.kind !== 'run') {
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'pipe-fitting') return { status: 'invalid' }
if (partner?.type !== 'pipe-fitting') return { status: 'invalid' }
const elbow = partner as PipeFittingNode
const profilePatch = pipeElbowProfilePatch(profile)
const profiledElbow = { ...elbow, ...profilePatch } as PipeFittingNode
@@ -0,0 +1,98 @@
import { describe, expect, test } from 'bun:test'
import { RoofSegmentNode } from '@pascal-app/core'
import { resolveRidgeSnap } from './ridge-snap'
describe('resolveRidgeSnap', () => {
test('snaps Dutch width-axis center clicks to the shortened top ridge', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
pitch: 40,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const center = resolveRidgeSnap(segment, 0, 0)
expect(center?.localX).toBeCloseTo(0)
expect(center?.localZ).toBeCloseTo(0)
expect(center?.rotation).toBeCloseTo(0)
})
test('snaps Dutch depth-axis center clicks to the shortened top ridge', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 6,
depth: 8,
pitch: 40,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const center = resolveRidgeSnap(segment, 0, 0)
expect(center?.localX).toBeCloseTo(0)
expect(center?.localZ).toBeCloseTo(0)
expect(center?.rotation).toBeCloseTo(Math.PI / 2)
})
test('snaps Dutch shoulder clicks onto the extended lower hip seam up to the rake end', () => {
const segment = RoofSegmentNode.parse({
roofType: 'dutch',
width: 8,
depth: 6,
pitch: 40,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const snap = resolveRidgeSnap(segment, 3.8, 2.8)
expect(snap).not.toBeNull()
expect(snap?.localX).toBeCloseTo(3.84, 2)
expect(snap?.localZ).toBeCloseTo(2.77, 2)
expect(Math.abs(snap?.rotation ?? 0)).toBeGreaterThan(0.1)
expect(Math.abs(Math.abs(snap?.rotation ?? 0) - Math.PI / 2)).toBeGreaterThan(0.1)
})
test('snaps mansard center clicks to the upper top ridge', () => {
const segment = RoofSegmentNode.parse({
roofType: 'mansard',
width: 8,
depth: 6,
pitch: 40,
})
const center = resolveRidgeSnap(segment, 0, 0)
expect(center?.localX).toBeCloseTo(0)
expect(center?.localZ).toBe(0)
expect(center?.rotation).toBeCloseTo(0)
})
test('snaps mansard lower-slope clicks to the nearest lower-slope vent line', () => {
const segment = RoofSegmentNode.parse({
roofType: 'mansard',
width: 8,
depth: 6,
pitch: 40,
overhang: 0,
wallThickness: 0,
shingleThickness: 0,
})
const frontRight = resolveRidgeSnap(segment, 3.5, 2.5)
const frontLeft = resolveRidgeSnap(segment, -3.5, 2.5)
expect(frontRight?.localX).toBeGreaterThan(0)
expect(frontRight?.localZ).toBeGreaterThan(0)
expect(frontLeft?.localX).toBeLessThan(0)
expect(frontLeft?.localZ).toBeGreaterThan(0)
expect(Math.abs(frontRight?.rotation ?? 0)).toBeGreaterThan(0.1)
expect(Math.abs(frontRight?.rotation ?? 0)).toBeLessThan(Math.PI / 2 - 0.1)
})
})
+59 -20
View File
@@ -1,48 +1,87 @@
import type { RoofSegmentNode } from '@pascal-app/core'
import {
getRidgeVentLinesForSegment,
getRoofSegmentVisibleTopBounds,
type RoofSegmentNode,
} from '@pascal-app/core'
/**
* Shared ridge-line snap math for ridge-vent placement + move tools.
*
* Ridge vents must sit centered on the segment's ridge — off-ridge the
* cap's far half dips into the higher part of the slope ("goes inside"
* the roof). So the placement tools clamp the cursor onto the ridge:
* closest-point projection along the segment's local X axis, with the X
* span clipped to where a real ridge actually exists for that roof type.
* Ridge vents must sit centered on a roof break line — off-line the cap's
* far half dips into the higher part of the slope ("goes inside" the roof).
* So the placement tools clamp the cursor onto the nearest generated ridge
* line, preserving the line's yaw for hip / lower-slope runs.
*
* Per roof type (the segment's ridge runs along the segment's local X):
* - gable / gambrel / dutch / mansard: ridge spans the full width.
* Per roof type:
* - gable / gambrel: ridge spans the full width.
* - mansard: top ridge, upper hip runs, plus lower-slope runs on all
* four steep lower faces.
* - dutch: top ridge between the gablet waists plus four hip runs down
* to the eave corners (the gablet ends are vertical walls, not ridges).
* - hip: ridge is shortened by the hipped ends — spans width depth.
* A square hip (width ≤ depth) collapses to a single apex point.
* - shed: no true ridge — snap to the high eave (z = -depth/2).
* - flat: no ridge at all → return null.
*/
// Standard lift above the analytical slope surface so the cap reads as
// sitting on the shingle course rather than clipping into it. Shared
// with the renderer so live ridge-Y derivation matches placement.
export const RIDGE_LIFT = 0.12
// Ridge vents seat directly onto the analytical roof surface; any visible
// thickness belongs in the vent geometry itself, not in a renderer lift.
export const RIDGE_LIFT = 0.09
export type RidgeSnap = {
/** Segment-local X of the snapped ridge position. */
localX: number
/** Segment-local Z of the snapped ridge position (0 for peaked roofs). */
/** Segment-local Z of the snapped ridge position. */
localZ: number
/** Segment-local yaw matching the snapped ridge line. */
rotation: number
}
export function resolveRidgeSnap(
segment: RoofSegmentNode,
cursorLocalX: number,
_cursorLocalZ: number,
cursorLocalZ: number,
): RidgeSnap | null {
const roofType = segment.roofType ?? 'gable'
if (roofType === 'flat') return null
const halfW = (segment.width ?? 0) / 2
const halfD = (segment.depth ?? 0) / 2
const lines =
roofType === 'shed'
? getShedHighEaveLine(segment)
: getRidgeVentLinesForSegment(segment).map(({ start, end }) => ({ start, end }))
if (lines.length === 0) return null
const ridgeZ = roofType === 'shed' ? -halfD : 0
const ridgeHalfLength = roofType === 'hip' ? Math.max(0, halfW - halfD) : halfW
const localX = Math.max(-ridgeHalfLength, Math.min(ridgeHalfLength, cursorLocalX))
let best: RidgeSnap | null = null
let bestDistanceSq = Number.POSITIVE_INFINITY
return { localX, localZ: ridgeZ }
for (const line of lines) {
const [sx, sz] = line.start
const [ex, ez] = line.end
const dx = ex - sx
const dz = ez - sz
const lengthSq = dx * dx + dz * dz
const t =
lengthSq <= 1e-8
? 0
: Math.max(0, Math.min(1, ((cursorLocalX - sx) * dx + (cursorLocalZ - sz) * dz) / lengthSq))
const localX = sx + dx * t
const localZ = sz + dz * t
const distanceSq = (cursorLocalX - localX) ** 2 + (cursorLocalZ - localZ) ** 2
if (distanceSq < bestDistanceSq) {
bestDistanceSq = distanceSq
best = {
localX,
localZ,
rotation: Math.atan2(-dz, dx),
}
}
}
return best
}
function getShedHighEaveLine(segment: RoofSegmentNode) {
const { minX, maxX, minZ } = getRoofSegmentVisibleTopBounds(segment)
return [{ start: [minX, minZ] as [number, number], end: [maxX, minZ] as [number, number] }]
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function RoofFaceHostFrame({
[storeSegment, liveOverride],
)
if (!segment || segment.type !== 'roof-segment' || !roofFace) return null
if (segment?.type !== 'roof-segment' || !roofFace) return null
const frame = getRoofWallFaceFrame(segment, roofFace)
return (
@@ -33,7 +33,7 @@ type SceneReader = { get: (id: AnyNodeId) => unknown }
function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) {
if (!(node.roofSegmentId && node.roofFace)) return null
const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined
if (!segment || segment.type !== 'roof-segment') return null
if (segment?.type !== 'roof-segment') return null
return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) }
}
@@ -9,6 +9,7 @@ import {
import * as THREE from 'three'
const worldPoint = new THREE.Vector3()
const localPoint = new THREE.Vector3()
export type RoofSegmentHit = {
segment: RoofSegmentNode
@@ -59,7 +60,7 @@ export function resolveRoofSegmentHit(
const segObj = sceneRegistry.nodes.get(seg.id)
if (!segObj) continue
segObj.updateWorldMatrix(true, false)
const local = segObj.worldToLocal(worldPoint.clone())
const local = segObj.worldToLocal(localPoint.copy(worldPoint))
if (!firstSegment) firstSegment = { seg, segObj }
@@ -81,7 +82,7 @@ export function resolveRoofSegmentHit(
if (best) return best.hit
if (firstSegment) {
const local = firstSegment.segObj.worldToLocal(worldPoint.clone())
const local = firstSegment.segObj.worldToLocal(localPoint.copy(worldPoint))
return {
segment: firstSegment.seg,
localX: local.x,
@@ -29,6 +29,11 @@ const EQUAL_SPACING_THRESHOLD_M = 0.03
const tmp = new THREE.Vector3()
const tmpA = new THREE.Vector3()
const tmpB = new THREE.Vector3()
const ROOF_SURFACE_FOOTPRINT_CACHE_MAX = 160
const roofSurfaceFootprintCache = new Map<
string,
Pick<RoofSurfaceGuideFootprint, 'width' | 'depth'> | null
>()
export type RoofSurfaceGuideMode = 'side-center' | 'linear-edge'
@@ -80,7 +85,7 @@ export function roofSurfaceFootprintFromNode(
options?: { segment?: RoofSegmentNode },
): RoofSurfaceGuideFootprint {
const n = node as Record<string, unknown>
const geometryBounds = geometryFootprintForNode(n, options?.segment)
const geometryBounds = cachedGeometryFootprintForNode(n, options?.segment)
if (geometryBounds) {
return {
...geometryBounds,
@@ -127,6 +132,43 @@ export function roofSurfaceFootprintFromNode(
}
}
function cachedGeometryFootprintForNode(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
): Pick<RoofSurfaceGuideFootprint, 'width' | 'depth'> | null {
const key = geometryFootprintCacheKey(node, segment)
if (roofSurfaceFootprintCache.has(key)) {
const cached = roofSurfaceFootprintCache.get(key)
return cached ? { ...cached } : null
}
const footprint = geometryFootprintForNode(node, segment)
roofSurfaceFootprintCache.set(key, footprint ? { ...footprint } : null)
if (roofSurfaceFootprintCache.size > ROOF_SURFACE_FOOTPRINT_CACHE_MAX) {
const oldestKey = roofSurfaceFootprintCache.keys().next().value
if (oldestKey) roofSurfaceFootprintCache.delete(oldestKey)
}
return footprint
}
function geometryFootprintCacheKey(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
): string {
const type = typeof node.type === 'string' ? node.type : 'unknown'
const segmentKey = type === 'chimney' && segment ? `|segment:${stableCacheKey(segment)}` : ''
return `${type}|node:${stableCacheKey(node)}${segmentKey}`
}
function stableCacheKey(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map((item) => stableCacheKey(item)).join(',')}]`
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => typeof entryValue !== 'function' && entryValue !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableCacheKey(entryValue)}`).join(',')}}`
}
function geometryFootprintForNode(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
@@ -40,6 +40,17 @@ describe('getDownSlopeYaw', () => {
test('flat segment has no down-slope direction (yaw 0)', () => {
expect(getDownSlopeYaw(0, 0, fixtureSegment({ roofType: 'flat' }))).toBe(0)
})
test('dutch width-axis shoulder falls toward the side eaves', () => {
expect(getDownSlopeYaw(3.8, 0, fixtureSegment({ roofType: 'dutch' }))).toBeCloseTo(Math.PI / 2)
})
test('dutch width-axis front skirt yaws toward the front eave', () => {
expect(getDownSlopeYaw(0, 1, fixtureSegment({ roofType: 'dutch' }))).toBeCloseTo(0)
})
test('dutch depth-axis top gable falls toward the side eaves', () => {
expect(
getDownSlopeYaw(1, 0, fixtureSegment({ roofType: 'dutch', width: 6, depth: 8 })),
).toBeCloseTo(Math.PI / 2)
})
})
describe('getRoofSurfaceFaceBoundsAt', () => {
@@ -63,4 +74,30 @@ describe('getRoofSurfaceFaceBoundsAt', () => {
expect(ridgeInterval?.[0]).toBeGreaterThan(-2)
expect(ridgeInterval?.[1]).toBeLessThan(2)
})
test('mansard top surface rises to a center ridge instead of staying flat', () => {
const segment = fixtureSegment({
roofType: 'mansard',
mansardSteepWidthRatio: 0.15,
mansardSteepHeightRatio: 0.7,
})
const center = getRoofSurfaceFaceBoundsAt(segment, 0, 0).surfaceYAt(0, 0)
const offRidge = getRoofSurfaceFaceBoundsAt(segment, 0, 0.5).surfaceYAt(0, 0.5)
expect(center).toBeGreaterThan(offRidge)
})
test('dutch top surface rises from the waist to the center ridge', () => {
const segment = fixtureSegment({
roofType: 'dutch',
dutchHipWidthRatio: 0.2,
dutchHipHeightRatio: 0.6,
})
const center = getRoofSurfaceFaceBoundsAt(segment, 0, 0).surfaceYAt(0, 0)
const waist = getRoofSurfaceFaceBoundsAt(segment, 0, 1.2).surfaceYAt(0, 1.2)
expect(center).toBeGreaterThan(waist)
})
})
+150 -258
View File
@@ -1,9 +1,11 @@
import {
getRoofModuleFaces,
getRoofSegmentSurfaceY,
getRoofShapeInsets,
getRoofShapeRatios,
getSegmentSlopeFrame,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
type RoofType,
} from '@pascal-app/core'
import * as THREE from 'three'
@@ -17,6 +19,10 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe
return getRoofSegmentSurfaceY(seg, lx, lz)
}
export function getRoofTopSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
return getRoofSurfaceFaceBoundsAt(seg, lx, lz).surfaceYAt(lx, lz)
}
export type RoofSurfacePoint2D = [number, number]
export type RoofSurfaceFaceBounds = {
@@ -36,9 +42,7 @@ export function getRoofSurfaceFaceBoundsAt(
lz: number,
): RoofSurfaceFaceBounds {
const faces = getRoofSurfaceFaces(segment)
const face =
faces.find((candidate) => pointInPolygon([lx, lz], candidate.polygon)) ??
nearestFaceToPoint(faces, [lx, lz])
const face = topmostFaceAtPoint(faces, lx, lz) ?? nearestFaceToPoint(faces, [lx, lz])
const { polygon } = face
const xs = polygon.map((point) => point[0])
@@ -61,23 +65,52 @@ type RoofSurfaceFace = {
vertices: FaceVertex[]
}
type FaceVertex = { x: number; y: number; z: number }
type FaceInsets = {
iF?: number
iB?: number
iL?: number
iR?: number
dutchI?: number
}
type FaceShapeRatios = {
gambrelLowerWidthRatio: number
mansardSteepWidthRatio: number
dutchHipWidthRatio: number
}
const SHINGLE_SURFACE_EPSILON = 0.02
const FACE_TOLERANCE = 1e-6
const ROOF_SURFACE_FACE_CACHE_MAX = 128
const roofSurfaceFaceCache = new Map<string, RoofSurfaceFace[]>()
const _downSlopeYawNormal = new THREE.Vector3()
const _surfaceQuatRight = new THREE.Vector3()
const _surfaceQuatForward = new THREE.Vector3()
const _surfaceQuatMatrix = new THREE.Matrix4()
function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
const key = roofSurfaceFaceCacheKey(segment)
const cached = roofSurfaceFaceCache.get(key)
if (cached) return cached
const faces = buildRoofSurfaceFaces(segment)
roofSurfaceFaceCache.set(key, faces)
if (roofSurfaceFaceCache.size > ROOF_SURFACE_FACE_CACHE_MAX) {
const oldestKey = roofSurfaceFaceCache.keys().next().value
if (oldestKey) roofSurfaceFaceCache.delete(oldestKey)
}
return faces
}
function roofSurfaceFaceCacheKey(segment: RoofSegmentNode): string {
return [
segment.roofType,
segment.width,
segment.depth,
segment.wallHeight,
segment.wallThickness,
segment.deckThickness,
segment.overhang,
segment.shingleThickness,
segment.pitch,
segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
segment.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio,
segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio,
segment.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio,
segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
segment.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
].join('|')
}
function buildRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment
const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment)
@@ -122,39 +155,43 @@ function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
const dropTop = Math.min(1, maxDrop * 0.4)
const topBaseY = shinBotWh - dropTop
const insetsTop = getRoofFaceInsets(
const dutchHipWidthRatio = segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio
const insetsTop = getRoofShapeInsets({
roofType,
width,
depth,
shinTopWh,
topBaseY,
false,
shinTopW,
shinTopD,
wh: shinTopWh,
baseY: topBaseY,
isVoid: false,
brushW: shinTopW,
brushD: shinTopD,
tanTheta,
shingleThickness,
)
const shapeRatios = {
gambrelLowerWidthRatio:
segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
mansardSteepWidthRatio:
segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio,
dutchHipWidthRatio: segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
}
dutchHipWidthRatio,
})
const shapeRatios = getRoofShapeRatios({
gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio,
mansardSteepWidthRatio: segment.mansardSteepWidthRatio,
dutchHipWidthRatio,
dutchHipHeightRatio: segment.dutchHipHeightRatio,
dutchWaistLengthRatio: segment.dutchWaistLengthRatio,
dutchGabletRake: segment.dutchGabletRake,
})
return getRoofModuleFaces(
roofType,
shinTopW,
shinTopD,
shinTopWh,
shinTopRh,
topBaseY,
insetsTop,
width,
depth,
return getRoofModuleFaces({
type: roofType,
w: shinTopW,
d: shinTopD,
wh: shinTopWh,
rh: shinTopRh,
baseY: topBaseY,
insets: insetsTop,
baseW: width,
baseD: depth,
tanTheta,
shapeRatios,
)
dutchTopRakeThickness: segment.dutchTopRakeThickness,
})
.filter((face) => faceNormalY(face) > SHINGLE_SURFACE_EPSILON)
.map((face) => {
const vertices = face.map((point) => ({ ...point, z: point.z + transZ }))
@@ -166,199 +203,23 @@ function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
.filter((face) => face.polygon.length >= 3)
}
function getRoofFaceInsets(
roofType: RoofType,
width: number,
depth: number,
wh: number,
baseY: number,
isVoid: boolean,
brushW: number,
brushD: number,
tanTheta: number,
shingleThickness: number,
): FaceInsets {
let inset = (wh - baseY) * tanTheta
const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005
if (inset > maxSafeInset) inset = maxSafeInset
function topmostFaceAtPoint(
faces: RoofSurfaceFace[],
lx: number,
lz: number,
): RoofSurfaceFace | null {
let best: RoofSurfaceFace | null = null
let bestY = Number.NEGATIVE_INFINITY
let iF = 0
let iB = 0
let iL = 0
let iR = 0
if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') {
iF = inset
iB = inset
iL = inset
iR = inset
} else if (roofType === 'gable' || roofType === 'gambrel') {
iF = inset
iB = inset
} else if (roofType === 'shed') {
iF = inset
for (const face of faces) {
if (!pointInPolygon([lx, lz], face.polygon)) continue
const y = surfaceYOnFace(face.vertices, lx, lz)
if (y === null || y <= bestY) continue
best = face
bestY = y
}
let dutchI = Math.min(width, depth) * 0.25
if (isVoid) dutchI += shingleThickness
return { iF, iB, iL, iR, dutchI }
}
function getRoofModuleFaces(
type: RoofType,
w: number,
d: number,
wh: number,
rh: number,
baseY: number,
insets: FaceInsets,
baseW: number,
baseD: number,
tanTheta: number,
shapeRatios: FaceShapeRatios,
): FaceVertex[][] {
const v = (x: number, y: number, z: number): FaceVertex => ({ x, y, z })
const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets
const b1 = v(-w / 2 + iL, baseY, d / 2 - iF)
const b2 = v(w / 2 - iR, baseY, d / 2 - iF)
const b3 = v(w / 2 - iR, baseY, -d / 2 + iB)
const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB)
const bottom = [b4, b3, b2, b1]
const e1 = v(-w / 2, wh, d / 2)
const e2 = v(w / 2, wh, d / 2)
const e3 = v(w / 2, wh, -d / 2)
const e4 = v(-w / 2, wh, -d / 2)
const faces: FaceVertex[][] = []
faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom)
const h = wh + Math.max(0.001, rh)
if (type === 'flat' || rh === 0) {
faces.push([e1, e2, e3, e4])
} else if (type === 'gable') {
const r1 = v(-w / 2, h, 0)
const r2 = v(w / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else if (type === 'hip') {
if (Math.abs(w - d) < 0.01) {
const r = v(0, h, 0)
faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r])
} else if (w >= d) {
const r1 = v(-w / 2 + d / 2, h, 0)
const r2 = v(w / 2 - d / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else {
const r1 = v(0, h, d / 2 - w / 2)
const r2 = v(0, h, -d / 2 + w / 2)
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])
}
} else if (type === 'shed') {
const t1 = v(-w / 2, h, -d / 2)
const t2 = v(w / 2, h, -d / 2)
faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1])
} else if (type === 'gambrel') {
const mz = (baseD / 2) * shapeRatios.gambrelLowerWidthRatio
const dist = d / 2 - mz
const mh = wh + dist * (tanTheta || 0)
const m1 = v(-w / 2, mh, mz)
const m2 = v(w / 2, mh, mz)
const m3 = v(w / 2, mh, -mz)
const m4 = v(-w / 2, mh, -mz)
const r1 = v(-w / 2, h, 0)
const r2 = v(w / 2, h, 0)
faces.push(
[e4, e1, m1, r1, m4],
[e2, e3, m3, r2, m2],
[e1, e2, m2, m1],
[m1, m2, r2, r1],
[e3, e4, m4, m3],
[m3, m4, r1, r2],
)
} else if (type === 'mansard') {
const i = Math.min(baseW, baseD) * shapeRatios.mansardSteepWidthRatio
const mh = wh + i * (tanTheta || 0)
const m1 = v(-w / 2 + i, mh, d / 2 - i)
const m2 = v(w / 2 - i, mh, d / 2 - i)
const m3 = v(w / 2 - i, mh, -d / 2 + i)
const m4 = v(-w / 2 + i, mh, -d / 2 + i)
const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2)
const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2)
const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2)
const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2)
if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) {
if (w >= d) {
const r1 = v(-w / 2 + d / 2, h, 0)
const r2 = v(w / 2 - d / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else {
const r1 = v(0, h, d / 2 - w / 2)
const r2 = v(0, h, -d / 2 + w / 2)
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])
}
} else {
faces.push(
[t1, t2, t3, t4],
[e1, e2, m2, m1],
[e2, e3, m3, m2],
[e3, e4, m4, m3],
[e4, e1, m1, m4],
[m1, m2, t2, t1],
[m2, m3, t3, t2],
[m3, m4, t4, t3],
[m4, m1, t1, t4],
)
}
} else if (type === 'dutch') {
const i =
insets.dutchI !== undefined
? insets.dutchI
: Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio
const mh = wh + i * (tanTheta || 0)
if (w >= d) {
const m1 = v(-w / 2 + i, mh, d / 2 - i)
const m2 = v(w / 2 - i, mh, d / 2 - i)
const m3 = v(w / 2 - i, mh, -d / 2 + i)
const m4 = v(-w / 2 + i, mh, -d / 2 + i)
const r1 = v(-w / 2 + i, h, 0)
const r2 = v(w / 2 - i, h, 0)
faces.push(
[e1, e2, m2, m1],
[e2, e3, m3, m2],
[e3, e4, m4, m3],
[e4, e1, m1, m4],
[m4, m1, r1],
[m2, m3, r2],
[m1, m2, r2, r1],
[m3, m4, r1, r2],
)
} else {
const m1 = v(-w / 2 + i, mh, d / 2 - i)
const m2 = v(w / 2 - i, mh, d / 2 - i)
const m3 = v(w / 2 - i, mh, -d / 2 + i)
const m4 = v(-w / 2 + i, mh, -d / 2 + i)
const r1 = v(0, h, d / 2 - i)
const r2 = v(0, h, -d / 2 + i)
faces.push(
[e1, e2, m2, m1],
[e2, e3, m3, m2],
[e3, e4, m4, m3],
[e4, e1, m1, m4],
[m1, m2, r1],
[m3, m4, r2],
[m2, m3, r2, r1],
[m4, m1, r1, r2],
)
}
}
return faces
return best
}
function faceNormalY(face: FaceVertex[]): number {
@@ -527,15 +388,20 @@ function lineInterval(
// down-slope direction (cos θ horizontal + sin θ vertical). Crossing
// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz),
// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised.
function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 {
return new THREE.Vector3(dx * tan, 1, dz * tan).normalize()
function buildSlopeNormal(dx: number, dz: number, tan: number, out: THREE.Vector3): THREE.Vector3 {
return out.set(dx * tan, 1, dz * tan).normalize()
}
export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 {
export function getAnalyticalNormal(
lx: number,
lz: number,
seg: RoofSegmentNode,
out = new THREE.Vector3(),
): THREE.Vector3 {
const { roofType, depth, width } = seg
const slope = getSegmentSlopeFrame(seg)
if (slope.activeRh === 0 || slope.tanTheta === 0) {
return new THREE.Vector3(0, 1, 0)
return out.set(0, 1, 0)
}
const primaryTan = slope.tanTheta
const halfW = width / 2
@@ -557,15 +423,15 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode
const upperRise = slope.activeRh * (1 - lowerHeightRatio)
const upperRun = mz
const upperTan = upperRun > 0 ? upperRise / upperRun : 0
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan)
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan, out)
}
}
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out)
}
// Single slope falling toward +Z (ridge at -Z, eave at +Z).
if (roofType === 'shed') {
return buildSlopeNormal(0, 1, primaryTan)
return buildSlopeNormal(0, 1, primaryTan, out)
}
// 4-sided slopes: the dominant axis chooses which face the point sits
@@ -574,10 +440,9 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode
// ends and gable sides — both share the same primaryTan from the
// slope frame, so directional dispatch is enough.
if (roofType === 'hip') {
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
const onZ = (halfD > 0 ? Math.abs(lz) / halfD : 0) >= (halfW > 0 ? Math.abs(lx) / halfW : 0)
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan, out)
}
if (roofType === 'mansard') {
@@ -597,21 +462,49 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode
const topRun = Math.max(0, Math.min(halfW, halfD) - inset)
tan = topRun > 0 ? topRise / topRun : 0
}
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan)
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan, out)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan, out)
}
if (roofType === 'dutch') {
// Hip on the short-axis ends, gable on the long-axis sides. Both
// share the primary pitch on their primary (eave-band) face, so the
// approximation collapses to "pick the dominant axis."
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
const inset =
Math.min(width, depth) * (seg.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio)
const heightRatio = seg.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio
const lengthRatio = seg.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio
const lowerRise = slope.activeRh * heightRatio
if (width >= depth) {
const waistHalfX = Math.max(0, (halfW - inset) * lengthRatio)
const waistHalfZ = Math.max(0, halfD - inset)
if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) {
const topRise = slope.activeRh * (1 - heightRatio)
const topTan = waistHalfZ > 0 ? topRise / waistHalfZ : 0
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, topTan, out)
}
const xRun = Math.max(0.0001, halfW - waistHalfX)
const zRun = Math.max(0.0001, halfD - waistHalfZ)
const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0
const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0
return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize()
}
const waistHalfX = Math.max(0, halfW - inset)
const waistHalfZ = Math.max(0, (halfD - inset) * lengthRatio)
if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) {
const topRise = slope.activeRh * (1 - heightRatio)
const topTan = waistHalfX > 0 ? topRise / waistHalfX : 0
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, topTan, out)
}
const xRun = Math.max(0.0001, halfW - waistHalfX)
const zRun = Math.max(0.0001, halfD - waistHalfZ)
const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0
const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0
return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize()
}
return new THREE.Vector3(0, 1, 0)
return out.set(0, 1, 0)
}
// ─── Quaternion helper ───────────────────────────────────────────────
@@ -628,8 +521,7 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni
// depending on which slope they sit on, and registry chevrons end up
// anchored to the wrong edge. Projecting +X keeps the basis stable
// across slope-flips that share the same X axis.
const wx = new THREE.Vector3(1, 0, 0)
const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal)))
const right = _surfaceQuatRight.set(1, 0, 0).addScaledVector(normal, -normal.x)
if (right.lengthSq() < 1e-6) {
// Degenerate: normal is parallel to ±X. Fall back to +Z so the basis
// is still well-defined; this is the wall-like edge case (vertical
@@ -638,9 +530,9 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni
} else {
right.normalize()
}
const forward = new THREE.Vector3().crossVectors(right, normal).normalize()
const m = new THREE.Matrix4().makeBasis(right, normal, forward)
return out.setFromRotationMatrix(m)
_surfaceQuatForward.crossVectors(right, normal).normalize()
_surfaceQuatMatrix.makeBasis(right, normal, _surfaceQuatForward)
return out.setFromRotationMatrix(_surfaceQuatMatrix)
}
// Yaw (about the surface normal, composed AFTER `surfaceQuatFromNormal`)
@@ -650,7 +542,7 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni
// → 0, Z → π, +X → +π/2, X → −π/2. Kept next to `surfaceQuatFromNormal`
// so the two stay in lockstep — the formula is only valid for its basis.
export function getDownSlopeYaw(lx: number, lz: number, seg: RoofSegmentNode): number {
const n = getAnalyticalNormal(lx, lz, seg)
const n = getAnalyticalNormal(lx, lz, seg, _downSlopeYawNormal)
if (n.x === 0 && n.z === 0) return 0
return Math.atan2(n.x * n.y, n.z)
}
@@ -166,7 +166,7 @@ export function planRunTranslationOffsets(args: {
}
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'duct-fitting') return null
if (partner?.type !== 'duct-fitting') return null
const elbow = {
...(partner as DuctFittingNode),
...elbowProfilePatch(profile),
@@ -0,0 +1,202 @@
import {
type AnyNodeId,
normalizeRoofSegmentTrim,
type RoofSegmentNode,
type RoofSegmentTrim,
useLiveNodeOverrides,
} from '@pascal-app/core'
import { clipGeometryBySegmentTrim } from '@pascal-app/viewer'
import { useEffect, useMemo } from 'react'
import * as THREE from 'three'
import { Matrix4 } from 'three'
// Does this segment remove any material? Mirrors the viewer's internal
// `hasSegmentTrim` (not exported) — the clip is a no-op otherwise, so the
// common (untrimmed) case skips all CSG work.
function segmentHasTrim(segment: RoofSegmentNode): boolean {
const t = normalizeRoofSegmentTrim(segment)
return (
t.left > 0 ||
t.right > 0 ||
t.front > 0 ||
t.back > 0 ||
t.frontLeftX > 0 ||
t.frontLeftZ > 0 ||
t.frontRightX > 0 ||
t.frontRightZ > 0 ||
t.backLeftX > 0 ||
t.backLeftZ > 0 ||
t.backRightX > 0 ||
t.backRightZ > 0
)
}
// A stable key for the segment's trim + footprint, so the memo only re-clips
// when the cut shape actually changes (not on unrelated segment edits like
// material).
function segmentTrimKey(segment: RoofSegmentNode | undefined): string {
if (!segment) return 'none'
const t = normalizeRoofSegmentTrim(segment)
return JSON.stringify([
segment.width,
segment.depth,
t.left,
t.right,
t.front,
t.back,
t.frontLeftX,
t.frontLeftZ,
t.frontRightX,
t.frontRightZ,
t.backLeftX,
t.backLeftZ,
t.backRightX,
t.backRightZ,
])
}
/**
* Clip a roof accessory's geometry by its host segment's trim, so the part of
* the accessory standing in a trimmed-away region is sliced off exactly like
* the roof shell.
*
* `geometry` is in accessory-local space; `localToSegment` maps it into the
* segment-local frame the trim cut prisms live in (compose the same
* `node.position` + inner-group quaternion the renderer mounts the mesh with).
* The function bakes that transform, runs `clipGeometryBySegmentTrim`, then
* strips the transform back off so the returned geometry is still
* accessory-local and drops straight into the renderer's existing mesh.
*
* Returns the input geometry untouched when the segment is missing or has no
* trim — zero cost for the overwhelmingly common case. The derived (clipped)
* geometry is owned by the hook and disposed on change / unmount; the input
* geometry is never consumed (we clip a clone), so the caller keeps owning it.
*/
export function useSegmentTrimClippedGeometry(
geometry: THREE.BufferGeometry | null,
segment: RoofSegmentNode | undefined,
localToSegment: THREE.Matrix4,
): THREE.BufferGeometry | null {
// Subscribe to the segment's live trim override so the accessory re-slices
// in lockstep with the trim handle drag (the editor publishes the in-flight
// trim to `useLiveNodeOverrides`; the store only updates on commit). Without
// this the accessory would only re-clip once the drag is released.
const liveTrim = useLiveNodeOverrides((s) =>
segment ? (s.get(segment.id as AnyNodeId)?.trim as RoofSegmentTrim | undefined) : undefined,
)
const effectiveSegment = useMemo(
() => (segment && liveTrim ? { ...segment, trim: liveTrim } : segment),
[segment, liveTrim],
)
const trimKey = segmentTrimKey(effectiveSegment)
// Matrix identity isn't stable across renders; key on its elements.
const matrixKey = localToSegment.elements.join(',')
const clipped = useMemo(() => {
if (!geometry || !effectiveSegment || !segmentHasTrim(effectiveSegment)) return null
const baked = geometry.clone()
baked.applyMatrix4(localToSegment)
const result = clipGeometryBySegmentTrim(baked, effectiveSegment)
if (!result) return null
// `clipGeometryBySegmentTrim` returns the same object when there's no trim
// (already guarded above) or a fresh clone otherwise; either way it's our
// `baked` clone or its descendant, safe to mutate + own.
const inverse = new Matrix4().copy(localToSegment).invert()
result.applyMatrix4(inverse)
result.computeVertexNormals()
// CSG can stamp the freshly-exposed cut faces with the cutter's material
// slot, which may exceed the accessory's material array (multi-slot kinds:
// dormer = 5 slots, solar-panel / skylight = 2). Clamp every group back into
// the original geometry's slot range so the renderer never indexes past its
// `material` array (mismatch crashes the draw, as the empty-segment
// placeholder guard documents).
const maxSlot = geometry.groups.reduce((m, g) => Math.max(m, g.materialIndex ?? 0), 0)
for (const g of result.groups) {
if ((g.materialIndex ?? 0) > maxSlot) g.materialIndex = maxSlot
}
return result
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [geometry, localToSegment, effectiveSegment])
useEffect(() => {
return () => {
clipped?.dispose()
}
}, [clipped])
// No trim (or no input) → render the original geometry unchanged.
return clipped ?? geometry
}
const _trimMeshLocal = new THREE.Matrix4()
const _trimMeshPos = new THREE.Vector3()
const _trimMeshQuat = new THREE.Quaternion()
const _trimMeshEuler = new THREE.Euler()
const _trimMeshScale = new THREE.Vector3(1, 1, 1)
/**
* A `<mesh>` whose geometry is sliced by the host roof segment's trim, for
* accessory sub-parts that live deeper than the registered group (skylight
* glass panes, dormer window glass / frame / sill). Pass the matrix that maps
* the part's own parent frame into the segment-local frame (`parentToSegment`)
* plus the part's local `position` / `rotation`; the component composes the
* full mesh→segment transform, clips, and renders the result at the same local
* pose. When the segment has no trim the original geometry renders unchanged.
*
* `geometry` is owned by the caller (built once and reused); the clipped
* derivative is owned by the hook and disposed on change / unmount.
*/
export function TrimClippedMesh({
geometry,
segment,
parentToSegment,
position = [0, 0, 0],
rotation = [0, 0, 0],
material,
name,
castShadow,
receiveShadow,
}: {
geometry: THREE.BufferGeometry
segment: RoofSegmentNode | undefined
parentToSegment: THREE.Matrix4
position?: [number, number, number]
rotation?: [number, number, number]
material: THREE.Material | THREE.Material[]
name?: string
castShadow?: boolean
receiveShadow?: boolean
}) {
// mesh→segment = parentToSegment · T(position) · R(rotation). Keyed in the
// hook on the matrix elements, so a moving/animated pane re-clips correctly.
const localToSegment = useMemo(() => {
_trimMeshEuler.set(rotation[0], rotation[1], rotation[2])
_trimMeshQuat.setFromEuler(_trimMeshEuler)
_trimMeshPos.set(position[0], position[1], position[2])
_trimMeshLocal.compose(_trimMeshPos, _trimMeshQuat, _trimMeshScale)
return new Matrix4().multiplyMatrices(parentToSegment, _trimMeshLocal)
}, [
parentToSegment,
position[0],
position[1],
position[2],
rotation[0],
rotation[1],
rotation[2],
])
const clipped = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
return (
<mesh
castShadow={castShadow}
geometry={clipped ?? geometry}
material={material}
name={name}
position={position}
receiveShadow={receiveShadow}
rotation={rotation}
/>
)
}
+3 -3
View File
@@ -590,7 +590,7 @@ function directRiserCollapseAction(args: {
)
if (!farPort) return null
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'duct-fitting') return null
if (lower?.type !== 'duct-fitting') return null
const lowerElbow = { ...(lower as DuctFittingNode), ...profilePatch } as DuctFittingNode
if (lowerElbow.fittingType !== 'elbow') return null
@@ -687,7 +687,7 @@ function fittingRiserAction(args: {
continue
}
const lower = nodesById[farPort.nodeId]
if (!lower || lower.type !== 'duct-fitting') {
if (lower?.type !== 'duct-fitting') {
if (stretch === 'stretch') return { status: 'stretch' }
continue
}
@@ -828,7 +828,7 @@ function planVerticalOffsetsAtDy(
// shows a red preview rather than dragging the network up to absorb it).
if (conn.kind !== 'run') {
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'duct-fitting') return { status: 'invalid' }
if (partner?.type !== 'duct-fitting') return { status: 'invalid' }
const elbow = partner as DuctFittingNode
const profilePatch = elbowProfilePatch(profile)
const profiledElbow = { ...elbow, ...profilePatch } as DuctFittingNode
+2 -2
View File
@@ -26,10 +26,10 @@ export function buildSkylightFloorplan(
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 -1
View File
@@ -142,7 +142,7 @@ export default function SkylightPanel() {
const state = useScene.getState()
const worldPt = new Vector3(wx, 0, wz)
for (const candidate of Object.values(state.nodes)) {
if (!candidate || candidate.type !== 'roof-segment') continue
if (candidate?.type !== 'roof-segment') continue
const seg = candidate as RoofSegmentNode
const segObj = sceneRegistry.nodes.get(seg.id)
if (!segObj) continue
+129 -5
View File
@@ -23,6 +23,7 @@ import {
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { surfaceQuatFromNormal } from '../shared/roof-surface'
import { TrimClippedMesh, useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildFrameGeometry } from './frame-csg'
import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry'
@@ -99,6 +100,8 @@ function GlassPane({
position = [0, 0, 0],
rotation,
width,
segment,
parentToSegment,
}: {
glassThickness: number
material: THREE.Material | THREE.Material[]
@@ -107,11 +110,42 @@ function GlassPane({
position?: [number, number, number]
rotation?: [number, number, number]
width: number
// Trim-clip context: when provided, the glass pane is sliced by the host
// segment's trim (so the glazing matches the roof cutaway). Absent in
// contexts with no host segment frame.
segment?: RoofSegmentNode
parentToSegment?: THREE.Matrix4
}) {
const geometry = useMemo(
() => new THREE.BoxGeometry(paneSize(width), paneSize(glassThickness), paneSize(paneDepth)),
[width, glassThickness, paneDepth],
)
useEffect(() => () => geometry.dispose(), [geometry])
if (segment && parentToSegment) {
return (
<TrimClippedMesh
geometry={geometry}
material={material}
name={name}
parentToSegment={parentToSegment}
position={position}
receiveShadow
rotation={rotation}
segment={segment}
/>
)
}
return (
<mesh material={material} name={name} position={position} receiveShadow rotation={rotation}>
<boxGeometry args={[paneSize(width), paneSize(glassThickness), paneSize(paneDepth)]} />
</mesh>
<mesh
geometry={geometry}
material={material}
name={name}
position={position}
receiveShadow
rotation={rotation}
/>
)
}
@@ -181,11 +215,15 @@ function LanternGlass({
frameMaterial,
glassMaterial,
node,
segment,
parentToSegment,
}: {
curbHeight: number
frameMaterial: THREE.Material | THREE.Material[]
glassMaterial: THREE.Material | THREE.Material[]
node: SkylightNode
segment?: RoofSegmentNode
parentToSegment?: THREE.Matrix4
}) {
const preset = SKYLIGHT_TYPE_PRESETS.lantern
const width = node.width - 0.01
@@ -228,9 +266,27 @@ function LanternGlass({
}
}, [geometry])
const glassParentToSegment =
segment && parentToSegment
? new THREE.Matrix4()
.copy(parentToSegment)
.multiply(new THREE.Matrix4().makeTranslation(0, curbHeight, 0))
: undefined
return (
<group position={[0, curbHeight, 0]}>
<mesh geometry={geometry} material={glassMaterial} name="skylight-glass" receiveShadow />
{segment && glassParentToSegment ? (
<TrimClippedMesh
geometry={geometry}
material={glassMaterial}
name="skylight-glass"
parentToSegment={glassParentToSegment}
receiveShadow
segment={segment}
/>
) : (
<mesh geometry={geometry} material={glassMaterial} name="skylight-glass" receiveShadow />
)}
{baseCorners.map((corner, index) => (
<FrameBar
end={baseCorners[(index + 1) % baseCorners.length] ?? corner}
@@ -351,6 +407,8 @@ function HingedGlass({
hasMotorHousing,
node,
openAmount,
segment,
parentToSegment,
}: {
curbHeight: number
frameMaterial: THREE.Material | THREE.Material[]
@@ -359,6 +417,8 @@ function HingedGlass({
hasMotorHousing: boolean
node: SkylightNode
openAmount: number
segment?: RoofSegmentNode
parentToSegment?: THREE.Matrix4
}) {
const preset = SKYLIGHT_TYPE_PRESETS.opening
const side = node.openingSide ?? preset.openingSide
@@ -376,6 +436,31 @@ function HingedGlass({
const supportEndY = curbHeight + glassThickness + Math.sin(openingAngle) * supportTravel
const supportEndZ = depth / 2 - Math.cos(openingAngle) * supportTravel
// The hinge group offsets + rotates the pane; compose that onto the
// skylight→segment matrix so the glass clips in segment-local space.
const hingeToSegment =
segment && parentToSegment
? new THREE.Matrix4()
.copy(parentToSegment)
.multiply(
new THREE.Matrix4().compose(
new THREE.Vector3(
transform.hingePosition[0],
curbHeight + glassThickness / 2,
transform.hingePosition[2],
),
new THREE.Quaternion().setFromEuler(
new THREE.Euler(
transform.rotation[0],
transform.rotation[1],
transform.rotation[2],
),
),
new THREE.Vector3(1, 1, 1),
),
)
: undefined
return (
<>
<group
@@ -390,7 +475,9 @@ function HingedGlass({
glassThickness={glassThickness}
material={glassMaterial}
paneDepth={depth}
parentToSegment={hingeToSegment}
position={transform.panePosition}
segment={segment}
width={width}
/>
<PaneFrame
@@ -438,6 +525,8 @@ function SlidingGlass({
glassThickness,
node,
openAmount,
segment,
parentToSegment,
}: {
curbHeight: number
frameMaterial: THREE.Material | THREE.Material[]
@@ -445,6 +534,8 @@ function SlidingGlass({
glassThickness: number
node: SkylightNode
openAmount: number
segment?: RoofSegmentNode
parentToSegment?: THREE.Matrix4
}) {
const preset = SKYLIGHT_TYPE_PRESETS.sliding
const slideDirection = node.slideDirection ?? preset.slideDirection
@@ -467,7 +558,9 @@ function SlidingGlass({
glassThickness={glassThickness}
material={glassMaterial}
paneDepth={node.height - 0.01}
parentToSegment={parentToSegment}
position={fixedPanePosition}
segment={segment}
width={paneWidth}
/>
<PaneFrame
@@ -482,7 +575,9 @@ function SlidingGlass({
glassThickness={glassThickness}
material={glassMaterial}
paneDepth={node.height - 0.01}
parentToSegment={parentToSegment}
position={movingPanePosition}
segment={segment}
width={paneWidth}
/>
<PaneFrame
@@ -524,7 +619,9 @@ function SlidingGlass({
glassThickness={glassThickness}
material={glassMaterial}
paneDepth={paneDepth}
parentToSegment={parentToSegment}
position={fixedPanePosition}
segment={segment}
width={node.width - 0.01}
/>
<PaneFrame
@@ -539,7 +636,9 @@ function SlidingGlass({
glassThickness={glassThickness}
material={glassMaterial}
paneDepth={paneDepth}
parentToSegment={parentToSegment}
position={movingPanePosition}
segment={segment}
width={node.width - 0.01}
/>
<PaneFrame
@@ -679,6 +778,23 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
const hasCurb = node.curb ?? false
const curbH = hasCurb ? Math.max(0, node.curbHeight ?? 0.1) : 0
// Map skylight-local geometry into the host segment's local frame (where the
// trim cut prisms live) — the same pose the inner registered group is mounted
// with (position [x, surfaceY, z] + surfaceQuat·yaw). Only the structural
// frame is clipped; the thin glass panes live inside animated sub-components
// (sliding / hinged) with their own internal transforms and ride with the
// frame. Computed before the early return so the hook order stays stable.
const localToSegment = useMemo(
() =>
new THREE.Matrix4().compose(
new THREE.Vector3(node.position[0] ?? 0, surfaceFrame.point.y, node.position[2] ?? 0),
composedQuat,
new THREE.Vector3(1, 1, 1),
),
[node.position[0], node.position[2], surfaceFrame.point.y, composedQuat],
)
const clippedFrame = useSegmentTrimClippedGeometry(frameGeo, segment, localToSegment)
if (!segment || !frameGeo) return null
const surfaceY = surfaceFrame.point.y
@@ -707,7 +823,7 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
>
<mesh
castShadow
geometry={frameGeo}
geometry={clippedFrame ?? frameGeo}
material={frameMaterial}
name="skylight-surface"
receiveShadow
@@ -718,6 +834,8 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
frameMaterial={frameMaterial}
glassMaterial={glassMaterial}
node={node}
parentToSegment={localToSegment}
segment={segment}
/>
)}
{activeType === 'sliding' && (
@@ -728,6 +846,8 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
glassThickness={glassThickness}
node={node}
openAmount={openAmount}
parentToSegment={localToSegment}
segment={segment}
/>
)}
{activeType === 'opening' && (
@@ -739,6 +859,8 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
hasMotorHousing={node.motorHousing ?? false}
node={node}
openAmount={openAmount}
parentToSegment={localToSegment}
segment={segment}
/>
)}
{(activeType === 'flat' || activeType === 'walk-on') && (
@@ -746,7 +868,9 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
glassThickness={glassThickness}
material={glassMaterial}
paneDepth={node.height + 0.004}
parentToSegment={localToSegment}
position={[0, curbH + glassThickness / 2, 0]}
segment={segment}
width={node.width + 0.004}
/>
)}
+4 -1
View File
@@ -20,6 +20,7 @@ import {
getSegmentGridStep,
isMagneticSnapActive,
markToolCancelConsumed,
projectAlignmentGuidesWorldToActiveBuildingLocal,
resolveAlignmentForActiveBuilding,
snapBuildingLocalToWorldGrid,
snapFenceDraftPoint,
@@ -205,7 +206,9 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
deltaX += result.snap.dx
deltaZ += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)
useAlignmentGuides
.getState()
.set(projectAlignmentGuidesWorldToActiveBuildingLocal(result.guides))
} else {
useAlignmentGuides.getState().clear()
}
@@ -135,9 +135,7 @@ describe('getAnalyticalNormal', () => {
expect(top.x).toBeGreaterThan(0)
expect(top.x).toBeLessThan(steep.x)
})
// Regression: dutch previously fell through to gable code, ignoring
// the X axis. Hip ends rendered with the wrong tilt direction.
test('dutch +x hip end tilts toward +x (w>=d)', () => {
test('dutch width-axis shoulder tilts toward +x when outside the waist span', () => {
const seg = fixtureSegment({ roofType: 'dutch', width: 8, depth: 6 })
const n = getAnalyticalNormal(seg.width / 2 - 0.01, 0, seg)
expect(n.x).toBeGreaterThan(0)
@@ -151,6 +149,18 @@ describe('getAnalyticalNormal', () => {
expect(Math.abs(n.x)).toBeLessThan(1e-6)
expect(n.y).toBeGreaterThan(0)
})
test('dutch top gable face keeps the same fall direction near the ridge', () => {
const seg = fixtureSegment({
roofType: 'dutch',
width: 8,
depth: 6,
dutchHipWidthRatio: 0.2,
dutchHipHeightRatio: 0.6,
})
const upper = getAnalyticalNormal(0, 0.01, seg)
expect(upper.z).toBeGreaterThan(0)
expect(Math.abs(upper.x)).toBeLessThan(1e-6)
})
})
describe('computeAutoFit', () => {
+2 -2
View File
@@ -31,10 +31,10 @@ export function buildSolarPanelFloorplan(
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
// Compose roof → segment → panel in plan coords. Each rotation is
// negated so SVG's y-down CW matches Three.js' top-down CCW.
+30 -1
View File
@@ -21,6 +21,7 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { surfaceQuatFromNormal } from '../shared/roof-surface'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildSolarPanelGeometry, getDefaultPanelMaterial } from './geometry'
// Module-scope scratch vectors and quaternions for composing the panel's
@@ -156,6 +157,34 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => {
[surfaceFrame.normal],
)
// Map panel-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 (position [x, surfaceY, z] + surfaceQuat·yaw·tilt) so the clip
// matches the rendered placement exactly. Computed before the early return so
// the hook order stays stable.
const localToSegment = useMemo(() => {
const surfaceY = surfaceFrame.point.y
const tiltRad = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0
const quat = new THREE.Quaternion()
.copy(surfaceQuat)
.multiply(new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0))
.multiply(new THREE.Quaternion().setFromAxisAngle(xAxis, tiltRad))
return new THREE.Matrix4().compose(
new THREE.Vector3(node.position[0] ?? 0, surfaceY, node.position[2] ?? 0),
quat,
new THREE.Vector3(1, 1, 1),
)
}, [
surfaceFrame.point.y,
surfaceQuat,
node.mountingType,
node.tiltAngle,
node.rotation,
node.position[0],
node.position[2],
])
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment)
if (!effectiveSegment || !geometry) return null
const surfaceY = surfaceFrame.point.y
@@ -190,7 +219,7 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => {
>
<mesh
castShadow
geometry={geometry}
geometry={clippedGeometry ?? geometry}
material={[frameMaterial, panelMaterial]}
name="solar-panel-surface"
receiveShadow
+1 -1
View File
@@ -50,7 +50,7 @@ export default function StairSegmentPanel() {
const isFirstSegment = useScene((s) => {
if (!node?.parentId) return true
const parent = s.nodes[node.parentId as AnyNodeId]
if (!parent || parent.type !== 'stair') return true
if (parent?.type !== 'stair') return true
const children = (parent as any).children ?? []
return children[0] === node.id
})
@@ -57,7 +57,7 @@ export const segmentWidthAffordance: FloorplanAffordance<StairNode> = {
const { segmentId, side, axisX } = payload as SegmentWidthPayload
const segmentNodeId = segmentId as AnyNodeId
const segment = nodes[segmentNodeId] as StairSegmentNode | undefined
if (!segment || segment.type !== 'stair-segment') return noopSession()
if (segment?.type !== 'stair-segment') return noopSession()
const initialWidth = segment.width
const sign = side === 'right' ? 1 : -1
@@ -96,7 +96,7 @@ export const segmentLengthAffordance: FloorplanAffordance<StairNode> = {
const { segmentId, axisZ } = payload as SegmentLengthPayload
const segmentNodeId = segmentId as AnyNodeId
const segment = nodes[segmentNodeId] as StairSegmentNode | undefined
if (!segment || segment.type !== 'stair-segment') return noopSession()
if (segment?.type !== 'stair-segment') return noopSession()
const initialLength = segment.length
const az = axisZ[0]
+27 -17
View File
@@ -309,6 +309,33 @@ export default function StairPanel() {
value={Math.round((node.openingOffset ?? 0) * 100) / 100}
/>
) : null}
{node.stairType === 'spiral' && (
<>
<div className="space-y-1.5">
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
Landing
</div>
<SegmentedControl
onChange={(value) => handleUpdate({ topLandingMode: value })}
options={TOP_LANDING_MODE_OPTIONS}
value={node.topLandingMode ?? 'none'}
/>
</div>
{(node.topLandingMode ?? 'none') === 'integrated' && (
<MetricControl
label="Top Landing"
max={5}
min={0.3}
onChange={(value) => handleUpdate({ topLandingDepth: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100}
/>
)}
</>
)}
</div>
</PanelSection>
@@ -415,23 +442,6 @@ export default function StairPanel() {
/>
{node.stairType === 'spiral' && (
<>
<SegmentedControl
onChange={(value) => handleUpdate({ topLandingMode: value })}
options={TOP_LANDING_MODE_OPTIONS}
value={node.topLandingMode ?? 'none'}
/>
{(node.topLandingMode ?? 'none') === 'integrated' && (
<MetricControl
label="Top Landing"
max={5}
min={0.3}
onChange={(value) => handleUpdate({ topLandingDepth: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100}
/>
)}
<ToggleControl
checked={node.showCenterColumn ?? true}
label="Center Column"
+1 -1
View File
@@ -1131,7 +1131,7 @@ function resolveLandingChainNextStair(
index: number,
): LandingChainNextStair {
const layout = layouts[index]
if (!layout || layout.segment.segmentType !== 'landing') {
if (layout?.segment.segmentType !== 'landing') {
return { isTerminalLandingBeforeStair: false }
}
+2 -2
View File
@@ -23,10 +23,10 @@ export function buildTurbineVentFloorplan(
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)
+31 -2
View File
@@ -20,6 +20,7 @@ import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildTurbineVentBase, buildTurbineVentHead } from './geometry'
const defaultMaterial = new THREE.MeshStandardMaterial({
@@ -114,6 +115,34 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) =>
return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat)
}, [surfaceQuat, node.rotation, yAxis])
const neckHForClip = Math.max(
0.02,
Math.min(node.neckHeight ?? 0.09, Math.max(0.12, node.height) * 0.5),
)
// Map vent-local geometry into the host segment's local frame (where the trim
// cut prisms live). The base sits at the inner group's pose; the head is
// nested one level deeper at [0, neckH, 0], so its clip matrix composes that
// offset. (When the head is spinning — opt-in, default paused — the cut edge
// rotates with it, which is acceptable for the animation.)
const baseLocalToSegment = useMemo(
() =>
new THREE.Matrix4().compose(
new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0),
composedQuat,
new THREE.Vector3(1, 1, 1),
),
[node.position[0], node.position[1], node.position[2], composedQuat],
)
const headLocalToSegment = useMemo(
() =>
new THREE.Matrix4()
.copy(baseLocalToSegment)
.multiply(new THREE.Matrix4().makeTranslation(0, neckHForClip, 0)),
[baseLocalToSegment, neckHForClip],
)
const clippedBase = useSegmentTrimClippedGeometry(baseGeometry, segment, baseLocalToSegment)
const clippedHead = useSegmentTrimClippedGeometry(headGeometry, segment, headLocalToSegment)
if (!segment) return null
// Replicate the parent segment's roof-local transform — see the long
@@ -133,7 +162,7 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) =>
>
<mesh
castShadow
geometry={baseGeometry}
geometry={clippedBase ?? baseGeometry}
material={material}
name="turbine-vent-base"
receiveShadow
@@ -142,7 +171,7 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) =>
<group position={[0, neckH, 0]} ref={headRef}>
<mesh
castShadow
geometry={headGeometry}
geometry={clippedHead ?? headGeometry}
material={material}
name="turbine-vent-head"
receiveShadow
+2 -2
View File
@@ -236,7 +236,7 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node })
canCommit() {
const live = useScene.getState().nodes[wallId] as WallNode | undefined
if (!live || live.type !== 'wall') return false
if (live?.type !== 'wall') return false
const [dx, dz] = lastDelta
return dx !== 0 || dz !== 0
},
@@ -244,7 +244,7 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node })
commit() {
const sceneState = useScene.getState()
const liveWall = sceneState.nodes[wallId] as WallNode | undefined
if (!liveWall || liveWall.type !== 'wall') {
if (liveWall?.type !== 'wall') {
// Bail without leaving stale overrides behind.
const overrides = useLiveNodeOverrides.getState()
overrides.clear(wallId)
@@ -25,7 +25,7 @@ export function wallFloorplanSiblingOverrides(args: {
let out: Record<AnyNodeId, AnyNode> | null = null
for (const [id, override] of liveOverrides) {
const existing = nodes[id as AnyNodeId]
if (!existing || existing.type !== 'wall') continue
if (existing?.type !== 'wall') continue
if (Object.keys(override).length === 0) continue
if (!out) out = { ...nodes }
out[id as AnyNodeId] = { ...existing, ...override } as AnyNode
+44 -9
View File
@@ -1,4 +1,5 @@
import {
type AnyNode,
calculateLevelMiters,
collectAlignmentAnchors,
emitter,
@@ -7,6 +8,7 @@ import {
type LevelNode,
type Point2D,
resolveAlignment,
resolveBuildingForLevel,
useScene,
type WallMiterData,
type WallNode,
@@ -464,17 +466,45 @@ function updateWallPreview(
mesh.geometry = geometry
}
function getCurrentLevelWalls(): WallNode[] {
const currentLevelId = useViewer.getState().selection.levelId
const { nodes } = useScene.getState()
if (!currentLevelId) return []
const levelNode = nodes[currentLevelId]
if (!levelNode || levelNode.type !== 'level') return []
function getLevelWalls(levelId: string | null, nodes: Record<string, AnyNode>): WallNode[] {
if (!levelId) return []
const levelNode = nodes[levelId]
if (levelNode?.type !== 'level') return []
return (levelNode as LevelNode).children
.map((childId) => nodes[childId])
.filter((node): node is WallNode => node?.type === 'wall')
}
function getCurrentLevelWalls(): WallNode[] {
const currentLevelId = useViewer.getState().selection.levelId
const { nodes } = useScene.getState()
return getLevelWalls(currentLevelId ?? null, nodes)
}
// Walls on the level directly beneath the active one. Levels share the same
// local XZ origin (they only differ in world Y), so these walls live in the
// identical coordinate frame and can be fed straight into the snap pipeline —
// letting the user draw a new wall aligned with the floor below. They are
// snap references only; `createWallOnCurrentLevel` re-derives its own
// current-level wall list, so the floor below is never split or mutated.
function getBelowLevelWalls(): WallNode[] {
const currentLevelId = useViewer.getState().selection.levelId
const { nodes } = useScene.getState()
if (!currentLevelId) return []
const currentLevel = nodes[currentLevelId]
if (currentLevel?.type !== 'level') return []
const buildingId = resolveBuildingForLevel(currentLevelId, nodes)
if (!buildingId) return []
const building = nodes[buildingId]
if (building?.type !== 'building') return []
const currentIndex = (currentLevel as LevelNode).level
const belowLevel = (building.children ?? [])
.map((childId) => nodes[childId])
.filter((node): node is LevelNode => node?.type === 'level' && node.level < currentIndex)
.sort((a, b) => b.level - a.level)[0]
return getLevelWalls(belowLevel?.id ?? null, nodes)
}
export const WallTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
@@ -558,6 +588,10 @@ export const WallTool: React.FC = () => {
if (!(cursorRef.current && wallPreviewRef.current)) return
const walls = getCurrentLevelWalls()
// Add walls on the floor below as extra snap references so the new wall
// can align with the level beneath it. Kept separate from `walls` so the
// measurement HUD only reports against the active level.
const snapWalls = [...walls, ...getBelowLevelWalls()]
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Snapping is governed entirely by the snapping mode (grid / lines /
// angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass.
@@ -566,7 +600,7 @@ export const WallTool: React.FC = () => {
const bypassAlign = !isMagneticSnapActive()
const snapResult = snapWallDraftPointDetailed({
point: localPoint,
walls,
walls: snapWalls,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
@@ -641,6 +675,7 @@ export const WallTool: React.FC = () => {
}
const walls = getCurrentLevelWalls()
const snapWalls = [...walls, ...getBelowLevelWalls()]
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Alignment guides follow the snapping mode (lines = magnetic on), not Alt.
@@ -650,7 +685,7 @@ export const WallTool: React.FC = () => {
const snappedStart = alignPoint(
snapWallDraftPointDetailed({
point: localClick,
walls,
walls: snapWalls,
magnetic: isMagneticSnapActive(),
}).point,
{ bypass: bypassAlign },
@@ -679,7 +714,7 @@ export const WallTool: React.FC = () => {
const snappedEnd = alignPoint(
snapWallDraftPointDetailed({
point: localClick,
walls,
walls: snapWalls,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
+1 -1
View File
@@ -25,7 +25,7 @@ export function buildWindowFloorplan(
ctx: GeometryContext,
): FloorplanGeometry | null {
const wall = ctx.parent as WallNode | null
if (!wall || wall.type !== 'wall') return null
if (wall?.type !== 'wall') return null
const [x1, z1] = wall.start
const [x2, z2] = wall.end