viewer: Fix Dutch roof trim artifacts (#452)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * Fix spiral stair openings and fence handle arrows * Implement roof trim planes and ridge vent clipping * Fix mansard roof and ridge vent placement * Fix mansard merged roof cutouts * Fix Dutch roof gable overhang * Refactor roof segment, ridge vent, and surface geometry Remove Dutch ridge axis abstraction and rework roof edit system, ridge vent clipping geometry, and roof surface placement. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Simplify Dutch roof shape * Add Dutch roof gable top geometry controls * Fix Dutch roof slope material slots * Render dutch roof tops as double-sided faces * Add auto ridge vent toggle to roof segments Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so geometry changes only regenerate default vents when enabled, treating legacy segments with generated vents as auto-enabled for back-compat. Expose a panel toggle to opt in/out per segment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Snap new walls to the floor below Feed the walls of the level directly beneath the active one into the draft snap pipeline as extra references, so a new wall can align with the floor below. They share the same local XZ origin, and the list is kept separate from the current-level walls so the measurement HUD and wall splitting only act on the active level. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Set Dutch roof shape defaults on type switch Seed the Dutch shape parameters (waist width/height/length, top rake thickness/length) with sensible defaults whenever a segment is created as or switched to Dutch, so the gablet is well-formed regardless of leftover values from the previous roof type. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Use green accent for corner and endpoint snap markers Color the corner/endpoint snap markers and the vertical cursor pillar green across the 2D floorplan beacon, the 3D alignment guide dots, and the wall snap beacon so snap targets read as a consistent accent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add magnetic wall snapping to the roof tool Snap roof draft corners onto wall corners, midpoints, crossings, and bodies on the active level and the floor below, reusing the wall tool's snap pipeline so the beacon and coloring match. The cursor's ground dot/ring is hidden while a wall snap is active to avoid overlapping the beacon glyph. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Update auto-generated Next.js route types path Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Show cutaway outline while dragging roof trim Slice an untrimmed segment volume generated from the live node instead of the registry mesh, whose CSG rebuild lags a few frames behind the drag and may still hold placeholder geometry — so the section outline now renders deterministically. Use LineBasicNodeMaterial so the outline draws under the WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fill and clip roof trim cutaway, gate it to active drag Add a violet silhouette fill behind the cutaway outline, extend the section slicing to angled diagonal/corner trims via a generic vertical cut plane, and clip each slice to its footprint span so the infinite plane no longer sprouts stray lines across the rest of the roof. The cutaway now renders only while a trim handle is being dragged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Separate and extend Dutch roof end slopes Pull the Dutch hip end slopes out of the watertight shingle shell into their own slab wedge so they can be reshaped independently, and extend each end slope inward up its own hip plane until the top edge meets the gablet's inner triangle. Refactor roof-segment shape geometry into a shared roof-segment-shape module. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Render roof trim cutaway as a material-only section cut Replace the triangle-mesh slicer with a CSG intersection of a thin slab against the untrimmed roof shell, so the cutaway shows red only on real material (wall + deck bands) and leaves the hollow attic empty. Add an analytic surface-edge outline, style both solid red like a SketchUp section, and make the cutaway persist whenever a segment is trimmed. Keep the merged roof shell visible during trim editing (re-trimmed live from each segment's drag override) instead of swapping in the per-segment meshes, whose abutting end-cap faces showed as stray white planes the commit never had. Extend each slab past free cut-line ends only — trimmed ends clamp to the cut line — so the red section stays inside the trim box. Re-export INTERSECTION from the viewer CSG surface for the editor. * Outline roof cutaway by fill silhouette, restyle to destructive red Derive the section-cut outline from the fill geometry's edges (EdgesGeometry) so it traces the real cut shape — wall/deck band boundaries and the hollow-attic edge — instead of just the top surface line. Drop the fill to 85% opacity and recolor both fill and outline to the app's destructive red, matching the delete/destructive UI. * Include roof accessories in trim clipping and red cutaway Roof accessories (chimney, vents, skylight, dormer, gutter, downspout, solar-panel, cupola) now slice at the trim plane like the roof shell and appear in the red section-cut while dragging a trim handle: - Export clipGeometryBySegmentTrim from the viewer as a reusable segment-local trim-clip primitive. - Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh wrapper (nodes) that slice accessory geometry by the host segment's live trim override, so the cut tracks the drag. - Wire the clip into all 11 accessory renderers, including skylight glass panes and dormer window glass/frame/sill. - Feed every hosted accessory mesh into the editor's red cutaway, welding triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a cross-section. - Register skylight in the scene-graph tree-node map so it shows in the outliner when placed on a roof. Co-Authored-By: Claude <noreply@anthropic.com> * Add smooth spline fences with editable curve handles Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an optional `path` (+ per-point `tangents`), selectable in a Straight/Curved mode toggle. Selected spline fences expose draggable control-point dots (hexagon) and symmetric tangent handles (circle) joined by a violet line, editable in both 2D plan and 3D. Side-move arrows are dropped for splines. Co-Authored-By: Claude <noreply@anthropic.com> * Fix dutch roof ridge vent handling * Fix Dutch ridge vent placement and support * Fix Dutch roof trim artifacts * Fix Dutch roof trim preview geometry * Tag roof trim overlay meshes with EDITOR_LAYER Child meshes relied on a parent group's layer, which three.js does not propagate, so the trim section/rail/plane overlays rendered on the scene layer — getting inked/SSGI-darkened and leaking into thumbnail exports. Co-Authored-By: Claude <noreply@anthropic.com> * Apply Biome cleanup * fix(core): address Dutch roof review feedback * chore: apply biome check cleanup * fix(core): relax Dutch roof surface helper input * fix * Fix biome checks and dev verification * fixes * Remove unsupported Biome noShadow override * Improve roof interactions and fence editing * Fix fence drag and ridge vent default handling * editor: drop wall-snap debug log, gate curved-fence finish hint on draft start Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove hot path. Curved fences commit on a closing gesture (double-click / Enter) rather than per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a point has been placed and a curve is actually in flight. The draft point count is published from SplineFenceDraft into a small ephemeral editor store (useFenceCurveDraft) that the contextual helper reads, mirroring the existing useSegmentDraftChain pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Wassim SAMAD
parent
c51e673421
commit
bf25af6add
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,8 +152,8 @@ const ridgeVentHandles: HandleDescriptor<RidgeVentNodeType>[] = [
|
||||
* geometry builder shared with the placement preview + future tests,
|
||||
* no animation or per-frame system.
|
||||
*
|
||||
* The placement tool snaps to the ridge (segment-local Z=0) wherever
|
||||
* the cursor lands on a segment.
|
||||
* The placement tool snaps to the nearest ridge/break line wherever the
|
||||
* cursor lands on a segment.
|
||||
*/
|
||||
export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
|
||||
kind: 'ridge-vent',
|
||||
|
||||
@@ -29,10 +29,10 @@ export function buildRidgeVentFloorplan(
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const segment = ctx.parent as RoofSegmentNode | null
|
||||
if (!segment || segment.type !== 'roof-segment') return null
|
||||
if (segment?.type !== 'roof-segment') return null
|
||||
const roofId = segment.parentId as AnyNodeId | null
|
||||
const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined
|
||||
if (!roof || roof.type !== 'roof') return null
|
||||
if (roof?.type !== 'roof') return null
|
||||
|
||||
const cosR = Math.cos(-roof.rotation)
|
||||
const sinR = Math.sin(-roof.rotation)
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
import type { RidgeVentNode } from '@pascal-app/core'
|
||||
import {
|
||||
getDutchRoofMetrics,
|
||||
getRidgeVentLinesForSegment,
|
||||
normalizeRoofSegmentTrim,
|
||||
type RidgeVentNode,
|
||||
type RoofSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { getRoofTopSurfaceY } from '../shared/roof-surface'
|
||||
|
||||
const ARC_SEGS = 16
|
||||
const SHINGLED_TAB_SIZE = 0.3
|
||||
const DEFAULT_RIDGE_VENT_LENGTH = 2
|
||||
const DEFAULT_RIDGE_VENT_WIDTH = 0.3
|
||||
const DEFAULT_RIDGE_VENT_HEIGHT = 0.1
|
||||
type ProfilePoint = [z: number, capY: number]
|
||||
type RidgeVentGeometryVertex = {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
nx: number
|
||||
ny: number
|
||||
nz: number
|
||||
u: number
|
||||
v: number
|
||||
}
|
||||
type SegmentTrimClipPlane = {
|
||||
signedDistance: (segmentX: number, segmentZ: number) => number
|
||||
}
|
||||
|
||||
type RidgeVentSupportLine = {
|
||||
startX: number
|
||||
endX: number
|
||||
name: string
|
||||
taperAtStart: boolean
|
||||
taperAtEnd: boolean
|
||||
}
|
||||
type RidgeVentEndTaper = {
|
||||
taperAtStart: boolean
|
||||
taperAtEnd: boolean
|
||||
taperLength: number
|
||||
tipHalfWidth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure builder for the ridge vent mesh. Each style is a peaked **band** of
|
||||
@@ -22,32 +60,136 @@ const SHINGLED_TAB_SIZE = 0.3
|
||||
*
|
||||
* `endCaps` closes both ends. Pure: no React, no scene access, no mutation.
|
||||
*/
|
||||
export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometry {
|
||||
const halfLen = node.length / 2
|
||||
const halfW = node.width / 2
|
||||
const h = node.height
|
||||
export function buildRidgeVentGeometry(
|
||||
node: RidgeVentNode,
|
||||
segment?: RoofSegmentNode,
|
||||
): THREE.BufferGeometry {
|
||||
const length = finitePositive(node.length, DEFAULT_RIDGE_VENT_LENGTH)
|
||||
const width = finitePositive(node.width, DEFAULT_RIDGE_VENT_WIDTH)
|
||||
const h = finitePositive(node.height, DEFAULT_RIDGE_VENT_HEIGHT)
|
||||
const halfLen = length / 2
|
||||
const halfW = width / 2
|
||||
// Band thickness. Generous enough to read as a solid cap; the eave faces
|
||||
// are `t` tall, which is the depth the user actually sees from the side.
|
||||
const t = Math.max(0.02, h * 0.4)
|
||||
|
||||
const centerX = finiteNumber(node.position?.[0], 0)
|
||||
const centerZ = finiteNumber(node.position?.[2], 0)
|
||||
const rotationY = finiteNumber(node.rotation, 0)
|
||||
const sinR = Math.sin(rotationY)
|
||||
const cosR = Math.cos(rotationY)
|
||||
const dutchTopRidgeSupport = getDutchTopRidgeSupport(segment, centerX, centerZ, rotationY)
|
||||
const surfaceYAt = (x: number, z: number) => {
|
||||
if (!segment) return 0
|
||||
let sampleX = centerX + x * cosR + z * sinR
|
||||
let sampleZ = centerZ - x * sinR + z * cosR
|
||||
if (dutchTopRidgeSupport) {
|
||||
if (dutchTopRidgeSupport.axis === 'x') {
|
||||
sampleX = clamp(
|
||||
sampleX,
|
||||
-dutchTopRidgeSupport.innerHalfSpan,
|
||||
dutchTopRidgeSupport.innerHalfSpan,
|
||||
)
|
||||
} else {
|
||||
sampleZ = clamp(
|
||||
sampleZ,
|
||||
-dutchTopRidgeSupport.innerHalfSpan,
|
||||
dutchTopRidgeSupport.innerHalfSpan,
|
||||
)
|
||||
}
|
||||
}
|
||||
return getRoofTopSurfaceY(sampleX, sampleZ, segment)
|
||||
}
|
||||
const ridgeY = surfaceYAt(0, 0)
|
||||
const seatYAt = (x: number, z: number) => (segment ? surfaceYAt(x, z) - ridgeY : 0)
|
||||
|
||||
const top =
|
||||
node.style === 'metal'
|
||||
? metalTop(halfW, h, t)
|
||||
: node.style === 'shingled'
|
||||
? shingledTop(halfW, h, t)
|
||||
: standardTop(halfW, h, t)
|
||||
const supportLine = segment ? getSupportLineForVent(segment, node) : null
|
||||
const endTaper = getRidgeVentEndTaper(supportLine, width, h)
|
||||
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
|
||||
buildBand(positions, normals, uvs, top, t, halfLen, node.endCaps)
|
||||
buildBand(positions, normals, uvs, top, seatYAt, -halfLen, halfLen, node.endCaps, endTaper)
|
||||
|
||||
if (node.style === 'shingled') {
|
||||
addShingledTabs(positions, normals, uvs, halfLen, top, h)
|
||||
addShingledTabs(positions, normals, uvs, -halfLen, halfLen, top, h, seatYAt, endTaper)
|
||||
}
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
const geometry = buildBufferGeometry(positions, normals, uvs)
|
||||
if (!segment) return geometry
|
||||
|
||||
const clipped = clipRidgeVentGeometryToSegmentTrim(geometry, node, segment)
|
||||
if (clipped !== geometry) geometry.dispose()
|
||||
return clipped
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
function finitePositive(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function getRidgeVentEndTaper(
|
||||
supportLine: RidgeVentSupportLine | null,
|
||||
width: number,
|
||||
height: number,
|
||||
): RidgeVentEndTaper | null {
|
||||
if (
|
||||
!supportLine ||
|
||||
(supportLine.name !== 'Hip Ridge Vent' && supportLine.name !== 'Slope Ridge Vent')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const lineLength = supportLine.endX - supportLine.startX
|
||||
const taperLength = Math.min(
|
||||
Math.max(0.07, width * 0.45, height * 0.9),
|
||||
Math.max(0, lineLength / 2 - 0.02),
|
||||
)
|
||||
if (!(taperLength > 0.001)) return null
|
||||
|
||||
return {
|
||||
taperAtStart: supportLine.taperAtStart,
|
||||
taperAtEnd: supportLine.taperAtEnd,
|
||||
taperLength,
|
||||
tipHalfWidth: Math.min(width * 0.34, Math.max(0.04, width * 0.26)),
|
||||
}
|
||||
}
|
||||
|
||||
function getDutchTopRidgeSupport(
|
||||
segment: RoofSegmentNode | undefined,
|
||||
centerX: number,
|
||||
centerZ: number,
|
||||
rotationY: number,
|
||||
): { axis: 'x' | 'z'; innerHalfSpan: number } | null {
|
||||
if (segment?.roofType !== 'dutch') return null
|
||||
|
||||
const metrics = getDutchRoofMetrics(segment)
|
||||
const onWidthAxisTopRidge =
|
||||
metrics.axis === 'x' && Math.abs(centerZ) <= 1e-4 && Math.abs(Math.sin(rotationY)) <= 1e-4
|
||||
const onDepthAxisTopRidge =
|
||||
metrics.axis === 'z' && Math.abs(centerX) <= 1e-4 && Math.abs(Math.cos(rotationY)) <= 1e-4
|
||||
|
||||
if (onWidthAxisTopRidge) {
|
||||
return { axis: 'x', innerHalfSpan: metrics.waistHalfX }
|
||||
}
|
||||
if (onDepthAxisTopRidge) {
|
||||
return { axis: 'z', innerHalfSpan: metrics.waistHalfZ }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Top profiles (open polylines eave → peak → eave, in [z, y]) ─────────
|
||||
@@ -55,8 +197,8 @@ export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometr
|
||||
// eaves, seating the cap on the roof while leaving a peaked void beneath.
|
||||
|
||||
// Smooth rounded arch.
|
||||
function standardTop(halfW: number, h: number, t: number): [number, number][] {
|
||||
const pts: [number, number][] = []
|
||||
function standardTop(halfW: number, h: number, t: number): ProfilePoint[] {
|
||||
const pts: ProfilePoint[] = []
|
||||
for (let i = 0; i <= ARC_SEGS; i++) {
|
||||
const frac = i / ARC_SEGS
|
||||
const z = -halfW + frac * 2 * halfW
|
||||
@@ -67,7 +209,7 @@ function standardTop(halfW: number, h: number, t: number): [number, number][] {
|
||||
}
|
||||
|
||||
// Angular peak with a narrow flat ridge at the top.
|
||||
function shingledTop(halfW: number, h: number, t: number): [number, number][] {
|
||||
function shingledTop(halfW: number, h: number, t: number): ProfilePoint[] {
|
||||
const peakHalf = halfW * 0.12
|
||||
return [
|
||||
[-halfW, t],
|
||||
@@ -78,7 +220,7 @@ function shingledTop(halfW: number, h: number, t: number): [number, number][] {
|
||||
}
|
||||
|
||||
// Bent-metal cap: steep folds up to a wide flat standing seam.
|
||||
function metalTop(halfW: number, h: number, t: number): [number, number][] {
|
||||
function metalTop(halfW: number, h: number, t: number): ProfilePoint[] {
|
||||
const seamHalf = halfW * 0.5
|
||||
const shoulderY = t + (h - t) * 0.5
|
||||
return [
|
||||
@@ -97,78 +239,97 @@ function buildBand(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
top: [number, number][],
|
||||
t: number,
|
||||
halfLen: number,
|
||||
top: ProfilePoint[],
|
||||
seatYAt: (x: number, z: number) => number,
|
||||
startX: number,
|
||||
endX: number,
|
||||
withCaps: boolean,
|
||||
endTaper: RidgeVentEndTaper | null = null,
|
||||
): void {
|
||||
const n = top.length
|
||||
// Underside: the same profile dropped straight down by `t` (eaves → y 0).
|
||||
const inner: [number, number][] = top.map(([z, y]) => [z, y - t])
|
||||
const halfWidth = getProfileHalfWidth(top)
|
||||
const stations = getRidgeVentSweepStations(startX, endX, endTaper)
|
||||
const scaledZAt = (x: number, z: number): number =>
|
||||
z * getProfileScaleAtX(x, startX, endX, halfWidth, endTaper)
|
||||
const seatAt = (x: number, z: number): number => seatYAt(x, scaledZAt(x, z))
|
||||
const topAt = (x: number, z: number, capY: number): number => seatAt(x, z) + capY
|
||||
|
||||
// Top surface + underside, swept along the ridge length.
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const [z0, y0] = top[i]!
|
||||
const [z1, y1] = top[i + 1]!
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, y0, z0],
|
||||
[halfLen, y0, z0],
|
||||
[halfLen, y1, z1],
|
||||
[-halfLen, y1, z1],
|
||||
[0, 1, 0],
|
||||
)
|
||||
const [iz0, iy0] = inner[i]!
|
||||
const [iz1, iy1] = inner[i + 1]!
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy0, iz0],
|
||||
[halfLen, iy0, iz0],
|
||||
[halfLen, iy1, iz1],
|
||||
[-halfLen, iy1, iz1],
|
||||
[0, -1, 0],
|
||||
)
|
||||
for (let station = 0; station < stations.length - 1; station += 1) {
|
||||
const x0 = stations[station]!
|
||||
const x1 = stations[station + 1]!
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const [z0, capY0] = top[i]!
|
||||
const [z1, capY1] = top[i + 1]!
|
||||
const x0z0 = scaledZAt(x0, z0)
|
||||
const x1z0 = scaledZAt(x1, z0)
|
||||
const x1z1 = scaledZAt(x1, z1)
|
||||
const x0z1 = scaledZAt(x0, z1)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x0, topAt(x0, z0, capY0), x0z0],
|
||||
[x1, topAt(x1, z0, capY0), x1z0],
|
||||
[x1, topAt(x1, z1, capY1), x1z1],
|
||||
[x0, topAt(x0, z1, capY1), x0z1],
|
||||
[0, 1, 0],
|
||||
)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x0, seatAt(x0, z0), x0z0],
|
||||
[x1, seatAt(x1, z0), x1z0],
|
||||
[x1, seatAt(x1, z1), x1z1],
|
||||
[x0, seatAt(x0, z1), x0z1],
|
||||
[0, -1, 0],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Eave thickness faces (the visible depth along each long edge).
|
||||
for (const idx of [0, n - 1]) {
|
||||
const [z, yTop] = top[idx]!
|
||||
const [, yInner] = inner[idx]!
|
||||
const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1]
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, yInner, z],
|
||||
[halfLen, yInner, z],
|
||||
[halfLen, yTop, z],
|
||||
[-halfLen, yTop, z],
|
||||
hint,
|
||||
)
|
||||
for (let station = 0; station < stations.length - 1; station += 1) {
|
||||
const x0 = stations[station]!
|
||||
const x1 = stations[station + 1]!
|
||||
for (const idx of [0, n - 1]) {
|
||||
const [z, capY] = top[idx]!
|
||||
const x0z = scaledZAt(x0, z)
|
||||
const x1z = scaledZAt(x1, z)
|
||||
const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1]
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x0, seatAt(x0, z), x0z],
|
||||
[x1, seatAt(x1, z), x1z],
|
||||
[x1, topAt(x1, z, capY), x1z],
|
||||
[x0, topAt(x0, z, capY), x0z],
|
||||
hint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// End caps: the band's cross-section ring at each end.
|
||||
if (withCaps) {
|
||||
for (const sign of [-1, 1] as const) {
|
||||
const x = sign * halfLen
|
||||
for (const [x, sign] of [
|
||||
[startX, -1],
|
||||
[endX, 1],
|
||||
] as const) {
|
||||
const hint: [number, number, number] = [sign, 0, 0]
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const [z0, y0] = top[i]!
|
||||
const [z1, y1] = top[i + 1]!
|
||||
const [iz0, iy0] = inner[i]!
|
||||
const [iz1, iy1] = inner[i + 1]!
|
||||
const [z0, capY0] = top[i]!
|
||||
const [z1, capY1] = top[i + 1]!
|
||||
const scaledZ0 = scaledZAt(x, z0)
|
||||
const scaledZ1 = scaledZAt(x, z1)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x, y0, z0],
|
||||
[x, y1, z1],
|
||||
[x, iy1, iz1],
|
||||
[x, iy0, iz0],
|
||||
[x, topAt(x, z0, capY0), scaledZ0],
|
||||
[x, topAt(x, z1, capY1), scaledZ1],
|
||||
[x, seatAt(x, z1), scaledZ1],
|
||||
[x, seatAt(x, z0), scaledZ0],
|
||||
hint,
|
||||
)
|
||||
}
|
||||
@@ -176,6 +337,45 @@ function buildBand(
|
||||
}
|
||||
}
|
||||
|
||||
function getProfileHalfWidth(top: ProfilePoint[]): number {
|
||||
return top.reduce((halfWidth, [z]) => Math.max(halfWidth, Math.abs(z)), 0)
|
||||
}
|
||||
|
||||
function getRidgeVentSweepStations(
|
||||
startX: number,
|
||||
endX: number,
|
||||
endTaper: RidgeVentEndTaper | null,
|
||||
): number[] {
|
||||
const stations = [startX, endX]
|
||||
if (endTaper?.taperAtStart) stations.push(startX + endTaper.taperLength)
|
||||
if (endTaper?.taperAtEnd) stations.push(endX - endTaper.taperLength)
|
||||
return stations
|
||||
.filter((x) => x >= startX && x <= endX)
|
||||
.sort((a, b) => a - b)
|
||||
.filter((x, index, sorted) => index === 0 || Math.abs(x - sorted[index - 1]!) > 1e-5)
|
||||
}
|
||||
|
||||
function getProfileScaleAtX(
|
||||
x: number,
|
||||
startX: number,
|
||||
endX: number,
|
||||
halfWidth: number,
|
||||
endTaper: RidgeVentEndTaper | null,
|
||||
): number {
|
||||
if (!endTaper || !(halfWidth > 0.0001)) return 1
|
||||
|
||||
const tipScale = clamp(endTaper.tipHalfWidth / halfWidth, 0, 1)
|
||||
if (endTaper.taperAtStart && x <= startX + endTaper.taperLength) {
|
||||
const progress = clamp((x - startX) / endTaper.taperLength, 0, 1)
|
||||
return lerp(tipScale, 1, progress)
|
||||
}
|
||||
if (endTaper.taperAtEnd && x >= endX - endTaper.taperLength) {
|
||||
const progress = clamp((endX - x) / endTaper.taperLength, 0, 1)
|
||||
return lerp(tipScale, 1, progress)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// ─── Shingled course ridges ──────────────────────────────────────────────
|
||||
// Thin raised lines running across the cap at intervals, suggesting
|
||||
// overlapping shingle courses. Sit on the top profile edges.
|
||||
@@ -184,21 +384,27 @@ function addShingledTabs(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
halfLen: number,
|
||||
top: [number, number][],
|
||||
startX: number,
|
||||
endX: number,
|
||||
top: ProfilePoint[],
|
||||
h: number,
|
||||
seatYAt: (x: number, z: number) => number,
|
||||
endTaper: RidgeVentEndTaper | null = null,
|
||||
): void {
|
||||
const totalLen = halfLen * 2
|
||||
const totalLen = endX - startX
|
||||
const numTabs = Math.max(2, Math.round(totalLen / SHINGLED_TAB_SIZE))
|
||||
const tabLen = totalLen / numTabs
|
||||
const ridgeH = h * 0.06
|
||||
const ridgeD = Math.min(0.01, tabLen * 0.15)
|
||||
|
||||
for (let tab = 1; tab < numTabs; tab++) {
|
||||
const x = -halfLen + tab * tabLen
|
||||
const x = startX + tab * tabLen
|
||||
if (isInsideEndTaper(x, startX, endX, endTaper)) continue
|
||||
for (let i = 0; i < top.length - 1; i++) {
|
||||
const [z0, y0] = top[i]!
|
||||
const [z1, y1] = top[i + 1]!
|
||||
const [z0, capY0] = top[i]!
|
||||
const [z1, capY1] = top[i + 1]!
|
||||
const y0 = seatYAt(x, z0) + capY0
|
||||
const y1 = seatYAt(x, z1) + capY1
|
||||
const dz = z1 - z0
|
||||
const dy = y1 - y0
|
||||
const len = Math.sqrt(dz * dz + dy * dy) || 1
|
||||
@@ -208,6 +414,11 @@ function addShingledTabs(
|
||||
const r0z = z0 + nz * ridgeH
|
||||
const r1y = y1 + ny * ridgeH
|
||||
const r1z = z1 + nz * ridgeH
|
||||
const backX = x - ridgeD
|
||||
const by0 = seatYAt(backX, z0) + capY0
|
||||
const by1 = seatYAt(backX, z1) + capY1
|
||||
const br0y = by0 + ny * ridgeH
|
||||
const br1y = by1 + ny * ridgeH
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
@@ -222,16 +433,29 @@ function addShingledTabs(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x - ridgeD, r0y, r0z],
|
||||
[x - ridgeD, r1y, r1z],
|
||||
[x - ridgeD, y1, z1],
|
||||
[x - ridgeD, y0, z0],
|
||||
[backX, br0y, r0z],
|
||||
[backX, br1y, r1z],
|
||||
[backX, by1, z1],
|
||||
[backX, by0, z0],
|
||||
[-1, 0, 0],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isInsideEndTaper(
|
||||
x: number,
|
||||
startX: number,
|
||||
endX: number,
|
||||
endTaper: RidgeVentEndTaper | null,
|
||||
): boolean {
|
||||
if (!endTaper) return false
|
||||
return (
|
||||
(endTaper.taperAtStart && x <= startX + endTaper.taperLength) ||
|
||||
(endTaper.taperAtEnd && x >= endX - endTaper.taperLength)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Geometry plumbing ───────────────────────────────────────────────────
|
||||
|
||||
function buildBufferGeometry(
|
||||
@@ -240,6 +464,13 @@ function buildBufferGeometry(
|
||||
uvs: number[],
|
||||
): THREE.BufferGeometry {
|
||||
const geo = new THREE.BufferGeometry()
|
||||
if (positions.length === 0) {
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
||||
geo.computeBoundingSphere()
|
||||
return geo
|
||||
}
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||
@@ -247,6 +478,274 @@ function buildBufferGeometry(
|
||||
return geo
|
||||
}
|
||||
|
||||
function clipRidgeVentGeometryToSegmentTrim(
|
||||
geometry: THREE.BufferGeometry,
|
||||
node: RidgeVentNode,
|
||||
segment: RoofSegmentNode,
|
||||
): THREE.BufferGeometry {
|
||||
const planes = getSegmentTrimClipPlanes(segment)
|
||||
if (planes.length === 0) return geometry
|
||||
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!position || !normal || !uv) return geometry
|
||||
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
|
||||
for (let i = 0; i < position.count; i += 3) {
|
||||
let polygon: RidgeVentGeometryVertex[] = [
|
||||
readGeometryVertex(position, normal, uv, i),
|
||||
readGeometryVertex(position, normal, uv, i + 1),
|
||||
readGeometryVertex(position, normal, uv, i + 2),
|
||||
]
|
||||
|
||||
for (const plane of planes) {
|
||||
polygon = clipPolygonToSegmentTrimPlane(polygon, plane, node)
|
||||
if (polygon.length < 3) break
|
||||
}
|
||||
|
||||
if (polygon.length < 3) continue
|
||||
for (let j = 1; j < polygon.length - 1; j += 1) {
|
||||
pushGeometryVertex(positions, normals, uvs, polygon[0]!)
|
||||
pushGeometryVertex(positions, normals, uvs, polygon[j]!)
|
||||
pushGeometryVertex(positions, normals, uvs, polygon[j + 1]!)
|
||||
}
|
||||
}
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
|
||||
function getSupportLineForVent(
|
||||
segment: RoofSegmentNode,
|
||||
node: RidgeVentNode,
|
||||
): RidgeVentSupportLine | null {
|
||||
const lines = getRidgeVentLinesForSegment(segment)
|
||||
if (lines.length === 0) return null
|
||||
|
||||
const centerX = finiteNumber(node.position?.[0], 0)
|
||||
const centerZ = finiteNumber(node.position?.[2], 0)
|
||||
const rotationY = finiteNumber(node.rotation, 0)
|
||||
const dirX = Math.cos(rotationY)
|
||||
const dirZ = -Math.sin(rotationY)
|
||||
|
||||
let best: RidgeVentSupportLine | null = null
|
||||
let bestScore = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const line of lines) {
|
||||
const [sx, sz] = line.start
|
||||
const [ex, ez] = line.end
|
||||
const lineDx = ex - sx
|
||||
const lineDz = ez - sz
|
||||
const lineLength = Math.hypot(lineDx, lineDz)
|
||||
if (!(lineLength > 1e-4)) continue
|
||||
|
||||
const unitX = lineDx / lineLength
|
||||
const unitZ = lineDz / lineLength
|
||||
const yawPenalty = 1 - Math.abs(unitX * dirX + unitZ * dirZ)
|
||||
const centerOffsetX = centerX - sx
|
||||
const centerOffsetZ = centerZ - sz
|
||||
const t = Math.max(0, Math.min(lineLength, centerOffsetX * unitX + centerOffsetZ * unitZ))
|
||||
const nearestX = sx + unitX * t
|
||||
const nearestZ = sz + unitZ * t
|
||||
const distanceSq = (centerX - nearestX) ** 2 + (centerZ - nearestZ) ** 2
|
||||
const score = distanceSq + yawPenalty * 6
|
||||
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
const startLocalX = (sx - centerX) * dirX + (sz - centerZ) * dirZ
|
||||
const endLocalX = (ex - centerX) * dirX + (ez - centerZ) * dirZ
|
||||
const startRadiusSq = sx * sx + sz * sz
|
||||
const endRadiusSq = ex * ex + ez * ez
|
||||
const outerIsStart = startRadiusSq > endRadiusSq
|
||||
const minIsStart = startLocalX <= endLocalX
|
||||
best = {
|
||||
startX: Math.min(startLocalX, endLocalX),
|
||||
endX: Math.max(startLocalX, endLocalX),
|
||||
name: line.name,
|
||||
taperAtStart: minIsStart ? outerIsStart : !outerIsStart,
|
||||
taperAtEnd: minIsStart ? !outerIsStart : outerIsStart,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
function readGeometryVertex(
|
||||
position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
|
||||
normal: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
|
||||
uv: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
|
||||
index: number,
|
||||
): RidgeVentGeometryVertex {
|
||||
return {
|
||||
x: position.getX(index),
|
||||
y: position.getY(index),
|
||||
z: position.getZ(index),
|
||||
nx: normal.getX(index),
|
||||
ny: normal.getY(index),
|
||||
nz: normal.getZ(index),
|
||||
u: uv.getX(index),
|
||||
v: uv.getY(index),
|
||||
}
|
||||
}
|
||||
|
||||
function pushGeometryVertex(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
vertex: RidgeVentGeometryVertex,
|
||||
) {
|
||||
positions.push(vertex.x, vertex.y, vertex.z)
|
||||
normals.push(vertex.nx, vertex.ny, vertex.nz)
|
||||
uvs.push(vertex.u, vertex.v)
|
||||
}
|
||||
|
||||
function clipPolygonToSegmentTrimPlane(
|
||||
polygon: RidgeVentGeometryVertex[],
|
||||
plane: SegmentTrimClipPlane,
|
||||
node: RidgeVentNode,
|
||||
): RidgeVentGeometryVertex[] {
|
||||
const next: RidgeVentGeometryVertex[] = []
|
||||
let previous = polygon[polygon.length - 1]!
|
||||
let previousDistance = getTrimClipDistance(previous, plane, node)
|
||||
let previousInside = previousDistance <= 1e-6
|
||||
|
||||
for (const current of polygon) {
|
||||
const currentDistance = getTrimClipDistance(current, plane, node)
|
||||
const currentInside = currentDistance <= 1e-6
|
||||
|
||||
if (currentInside) {
|
||||
if (!previousInside) {
|
||||
next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance))
|
||||
}
|
||||
next.push(current)
|
||||
} else if (previousInside) {
|
||||
next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance))
|
||||
}
|
||||
|
||||
previous = current
|
||||
previousDistance = currentDistance
|
||||
previousInside = currentInside
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function getTrimClipDistance(
|
||||
vertex: RidgeVentGeometryVertex,
|
||||
plane: SegmentTrimClipPlane,
|
||||
node: RidgeVentNode,
|
||||
): number {
|
||||
const centerX = finiteNumber(node.position?.[0], 0)
|
||||
const centerZ = finiteNumber(node.position?.[2], 0)
|
||||
const rotationY = finiteNumber(node.rotation, 0)
|
||||
const segmentX = centerX + vertex.x * Math.cos(rotationY) + vertex.z * Math.sin(rotationY)
|
||||
const segmentZ = centerZ - vertex.x * Math.sin(rotationY) + vertex.z * Math.cos(rotationY)
|
||||
return plane.signedDistance(segmentX, segmentZ)
|
||||
}
|
||||
|
||||
function interpolateGeometryVertex(
|
||||
a: RidgeVentGeometryVertex,
|
||||
b: RidgeVentGeometryVertex,
|
||||
distanceA: number,
|
||||
distanceB: number,
|
||||
): RidgeVentGeometryVertex {
|
||||
const t = distanceA / (distanceA - distanceB || 1)
|
||||
return {
|
||||
x: lerp(a.x, b.x, t),
|
||||
y: lerp(a.y, b.y, t),
|
||||
z: lerp(a.z, b.z, t),
|
||||
nx: lerp(a.nx, b.nx, t),
|
||||
ny: lerp(a.ny, b.ny, t),
|
||||
nz: lerp(a.nz, b.nz, t),
|
||||
u: lerp(a.u, b.u, t),
|
||||
v: lerp(a.v, b.v, t),
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
|
||||
function getSegmentTrimClipPlanes(segment: RoofSegmentNode): SegmentTrimClipPlane[] {
|
||||
const trim = normalizeRoofSegmentTrim(segment)
|
||||
const planes: SegmentTrimClipPlane[] = []
|
||||
const leftX = -segment.width / 2 + trim.left
|
||||
const rightX = segment.width / 2 - trim.right
|
||||
const frontZ = segment.depth / 2 - trim.front
|
||||
const backZ = -segment.depth / 2 + trim.back
|
||||
|
||||
if (trim.left > 0) planes.push({ signedDistance: (x) => leftX - x })
|
||||
if (trim.right > 0) planes.push({ signedDistance: (x) => x - rightX })
|
||||
if (trim.front > 0) planes.push({ signedDistance: (_x, z) => z - frontZ })
|
||||
if (trim.back > 0) planes.push({ signedDistance: (_x, z) => backZ - z })
|
||||
|
||||
const diagonalPlane = (
|
||||
lineA: readonly [number, number],
|
||||
lineB: readonly [number, number],
|
||||
outsidePoint: readonly [number, number],
|
||||
): SegmentTrimClipPlane | null => {
|
||||
const dx = lineB[0] - lineA[0]
|
||||
const dz = lineB[1] - lineA[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (!(length > 0)) return null
|
||||
let nx = -dz / length
|
||||
let nz = dx / length
|
||||
const midX = (lineA[0] + lineB[0]) / 2
|
||||
const midZ = (lineA[1] + lineB[1]) / 2
|
||||
if (nx * (outsidePoint[0] - midX) + nz * (outsidePoint[1] - midZ) < 0) {
|
||||
nx *= -1
|
||||
nz *= -1
|
||||
}
|
||||
return {
|
||||
signedDistance: (x, z) => nx * (x - midX) + nz * (z - midZ),
|
||||
}
|
||||
}
|
||||
|
||||
const pushDiagonalPlane = (
|
||||
lineA: readonly [number, number],
|
||||
lineB: readonly [number, number],
|
||||
outsidePoint: readonly [number, number],
|
||||
) => {
|
||||
const plane = diagonalPlane(lineA, lineB, outsidePoint)
|
||||
if (plane) planes.push(plane)
|
||||
}
|
||||
|
||||
if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) {
|
||||
pushDiagonalPlane(
|
||||
[leftX + trim.frontLeftX, frontZ],
|
||||
[leftX, frontZ - trim.frontLeftZ],
|
||||
[leftX - 1, frontZ + 1],
|
||||
)
|
||||
}
|
||||
if (trim.frontRightX > 0 && trim.frontRightZ > 0) {
|
||||
pushDiagonalPlane(
|
||||
[rightX, frontZ - trim.frontRightZ],
|
||||
[rightX - trim.frontRightX, frontZ],
|
||||
[rightX + 1, frontZ + 1],
|
||||
)
|
||||
}
|
||||
if (trim.backLeftX > 0 && trim.backLeftZ > 0) {
|
||||
pushDiagonalPlane(
|
||||
[leftX, backZ + trim.backLeftZ],
|
||||
[leftX + trim.backLeftX, backZ],
|
||||
[leftX - 1, backZ - 1],
|
||||
)
|
||||
}
|
||||
if (trim.backRightX > 0 && trim.backRightZ > 0) {
|
||||
pushDiagonalPlane(
|
||||
[rightX - trim.backRightX, backZ],
|
||||
[rightX, backZ + trim.backRightZ],
|
||||
[rightX + 1, backZ - 1],
|
||||
)
|
||||
}
|
||||
|
||||
return planes
|
||||
}
|
||||
|
||||
// Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so
|
||||
// the shared flat normal points toward `hint`. UVs are dimension-based so
|
||||
// painted presets tile at world scale across the ridge length and the cap.
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
roofSegmentLocalToBuildingLocal,
|
||||
snapRelativeRoofDragTarget,
|
||||
} from '../shared/relative-roof-drag'
|
||||
import { getSurfaceY } from '../shared/roof-surface'
|
||||
import { resolveRidgeSnap } from '../shared/ridge-snap'
|
||||
import { getRoofTopSurfaceY } from '../shared/roof-surface'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfaceNodePlacementGuides,
|
||||
@@ -32,7 +33,8 @@ import RidgeVentPreview from './preview'
|
||||
|
||||
type RidgeVentDragTarget = Pick<RelativeRoofDragTarget, 'segment' | 'localX'> & {
|
||||
localY: number
|
||||
localZ: 0
|
||||
localZ: number
|
||||
rotation: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,11 +87,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
const rawTarget = roofDrag.resolve(event)
|
||||
if (!rawTarget) return null
|
||||
const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true)
|
||||
const snap = resolveRidgeSnap(target.segment, target.localX, target.localZ)
|
||||
if (!snap) return null
|
||||
const yOffset = original.position[1] ?? 0
|
||||
return {
|
||||
segment: target.segment,
|
||||
localX: target.localX,
|
||||
localY: getSurfaceY(target.localX, 0, target.segment),
|
||||
localZ: 0,
|
||||
localX: snap.localX,
|
||||
localY: getRoofTopSurfaceY(snap.localX, snap.localZ, target.segment) + yOffset,
|
||||
localZ: snap.localZ,
|
||||
rotation: snap.rotation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +117,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0) + target.rotation)
|
||||
setPreviewPos(
|
||||
roofSegmentLocalToBuildingLocal(target.segment.id, [
|
||||
target.localX,
|
||||
@@ -158,8 +164,8 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: targetSegmentId,
|
||||
parentId: targetSegmentId,
|
||||
position: [target.localX, target.localY, target.localZ],
|
||||
rotation: original.rotation,
|
||||
position: [target.localX, original.position[1] ?? 0, target.localZ],
|
||||
rotation: target.rotation,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
getEffectiveRoofSurfaceMaterial,
|
||||
getEffectiveSegmentSurfaceMaterial,
|
||||
type RidgeVentNode,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
@@ -13,25 +16,54 @@ import {
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
createSurfaceRoleMaterial,
|
||||
getRoofMaterialArray,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap'
|
||||
import { getSurfaceY } from '../shared/roof-surface'
|
||||
import { getRoofTopSurfaceY } from '../shared/roof-surface'
|
||||
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
|
||||
import { buildRidgeVentGeometry } from './geometry'
|
||||
|
||||
// Single white fallback for every style. Paint customisation comes from
|
||||
// `node.material` / `node.materialPreset` (default: `preset-white`); the
|
||||
// fallback only fires for legacy nodes that pre-date the schema default
|
||||
// and shouldn't punish them with style-specific grey/metal that diverges
|
||||
// from the "default white" the inspector advertises.
|
||||
const defaultMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 0.85,
|
||||
metalness: 0.1,
|
||||
})
|
||||
function ridgeVentSegmentGeometryKey(segment: RoofSegmentNode | undefined): string {
|
||||
if (!segment) return 'none'
|
||||
const trim = segment.trim
|
||||
return [
|
||||
segment.roofType,
|
||||
segment.width,
|
||||
segment.depth,
|
||||
segment.wallHeight,
|
||||
segment.pitch,
|
||||
segment.wallThickness,
|
||||
segment.deckThickness,
|
||||
segment.overhang,
|
||||
segment.shingleThickness,
|
||||
segment.gambrelLowerWidthRatio,
|
||||
segment.gambrelLowerHeightRatio,
|
||||
segment.mansardSteepWidthRatio,
|
||||
segment.mansardSteepHeightRatio,
|
||||
segment.dutchHipWidthRatio,
|
||||
segment.dutchHipHeightRatio,
|
||||
trim.left,
|
||||
trim.right,
|
||||
trim.front,
|
||||
trim.back,
|
||||
trim.frontLeft,
|
||||
trim.frontRight,
|
||||
trim.backLeft,
|
||||
trim.backRight,
|
||||
trim.frontLeftX,
|
||||
trim.frontLeftZ,
|
||||
trim.frontRightX,
|
||||
trim.frontRightZ,
|
||||
trim.backLeftX,
|
||||
trim.backLeftZ,
|
||||
trim.backRightX,
|
||||
trim.backRightZ,
|
||||
].join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ridge vent renderer. Sits along the ridge of a roof-segment — no
|
||||
@@ -66,6 +98,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
|
||||
const node: RidgeVentNode = overrides
|
||||
? ({ ...storeNode, ...overrides } as RidgeVentNode)
|
||||
: storeNode
|
||||
const nodePosition = node.position ?? [0, 0, 0]
|
||||
|
||||
const segmentStore = useScene((state) =>
|
||||
node.roofSegmentId
|
||||
@@ -87,11 +120,44 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
|
||||
? ({ ...segmentStore, ...segmentOverrides } as RoofSegmentNode)
|
||||
: segmentStore
|
||||
: undefined
|
||||
const parentRoof = useScene((state) =>
|
||||
segmentStore?.parentId
|
||||
? (state.nodes[segmentStore.parentId as AnyNodeId] as RoofNode | undefined)
|
||||
: undefined,
|
||||
)
|
||||
const segmentGeometryKey = ridgeVentSegmentGeometryKey(segment)
|
||||
const rotationY = node.rotation ?? 0
|
||||
const snap = useMemo(
|
||||
() =>
|
||||
segment && Math.abs(rotationY) < 1e-5
|
||||
? resolveRidgeSnap(segment, nodePosition[0] ?? 0, nodePosition[2] ?? 0)
|
||||
: null,
|
||||
[segment, rotationY, nodePosition[0], nodePosition[2]],
|
||||
)
|
||||
const ridgeX = snap ? snap.localX : (nodePosition[0] ?? 0)
|
||||
const ridgeZ = snap ? snap.localZ : (nodePosition[2] ?? 0)
|
||||
const effectiveNode = useMemo<RidgeVentNode>(
|
||||
() => ({
|
||||
...node,
|
||||
position: [ridgeX, nodePosition[1] ?? 0, ridgeZ],
|
||||
}),
|
||||
[node, ridgeX, ridgeZ, nodePosition[1]],
|
||||
)
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `segmentGeometryKey` captures the segment fields that affect the generated mesh; depending on the whole segment would rebuild on unrelated node changes.
|
||||
const geometry = useMemo(
|
||||
() => buildRidgeVentGeometry(node),
|
||||
[node.length, node.width, node.height, node.style, node.endCaps],
|
||||
() => buildRidgeVentGeometry(effectiveNode, segment),
|
||||
[
|
||||
effectiveNode.length,
|
||||
effectiveNode.width,
|
||||
effectiveNode.height,
|
||||
effectiveNode.style,
|
||||
effectiveNode.endCaps,
|
||||
effectiveNode.rotation,
|
||||
effectiveNode.position[0],
|
||||
effectiveNode.position[2],
|
||||
segmentGeometryKey,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
@@ -104,13 +170,62 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
|
||||
// closed solid in `geometry.ts` is the right fix if the underside-view
|
||||
// becomes noticeable.
|
||||
const material = useMemo(() => {
|
||||
if (!textures || (!node.material && !node.materialPreset)) {
|
||||
return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
|
||||
const createDefaultTopMaterial = () => {
|
||||
const parentSpec = parentRoof ? getEffectiveRoofSurfaceMaterial(parentRoof, 'top') : undefined
|
||||
const spec = segment ? getEffectiveSegmentSurfaceMaterial(segment, 'top', parentSpec) : null
|
||||
|
||||
if (typeof spec?.materialPreset === 'string') {
|
||||
const resolved = createMaterialFromPresetRef(spec.materialPreset, shading)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
if (spec?.material !== undefined) {
|
||||
return createMaterial(spec.material, shading)
|
||||
}
|
||||
|
||||
const roofMaterials = parentRoof
|
||||
? getRoofMaterialArray(parentRoof, shading, textures, colorPreset, sceneTheme)
|
||||
: null
|
||||
return (
|
||||
roofMaterials?.[3] ??
|
||||
createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
|
||||
)
|
||||
}
|
||||
return node.material
|
||||
? createMaterial(node.material, shading)
|
||||
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
|
||||
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
|
||||
|
||||
if (node.material) {
|
||||
return createMaterial(node.material, shading)
|
||||
}
|
||||
if (node.materialPreset) {
|
||||
return createMaterialFromPresetRef(node.materialPreset, shading) ?? createDefaultTopMaterial()
|
||||
}
|
||||
return createDefaultTopMaterial()
|
||||
}, [
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
shading,
|
||||
node.material,
|
||||
node.materialPreset,
|
||||
segment,
|
||||
parentRoof,
|
||||
])
|
||||
|
||||
// Map vent-local geometry into the host segment's local frame (where the trim
|
||||
// cut prisms live). Recompose the same pose the inner mesh group is mounted
|
||||
// with — ridge snap for X/Z, slope-locked base Y + offset, yaw — so the clip
|
||||
// matches the rendered placement. Computed before the early return so the
|
||||
// hook order stays stable.
|
||||
const localToSegment = useMemo(() => {
|
||||
if (!segment) return new THREE.Matrix4()
|
||||
const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment)
|
||||
const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0))
|
||||
const ridgeY = baseY + RIDGE_LIFT + yOffset
|
||||
return new THREE.Matrix4().compose(
|
||||
new THREE.Vector3(ridgeX, ridgeY, ridgeZ),
|
||||
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotationY),
|
||||
new THREE.Vector3(1, 1, 1),
|
||||
)
|
||||
}, [segment, ridgeX, ridgeZ, rotationY, nodePosition[1]])
|
||||
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
|
||||
|
||||
if (!segment) return null
|
||||
|
||||
@@ -126,33 +241,29 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
|
||||
const segPos = segment.position ?? [0, 0, 0]
|
||||
const segRotY = segment.rotation ?? 0
|
||||
|
||||
// Lock the BASE position to the ridge so the vent always starts on the
|
||||
// slope top; treat `position[1]` and `position[2]` as user-tunable OFFSETS
|
||||
// off that base (Y above ridge lift, Z away from ridge centerline). So
|
||||
// Lock the BASE position to the rendered roof skin so the vent always starts
|
||||
// on the roof structure; treat `position[1]` and `position[2]` as user-tunable OFFSETS
|
||||
// off that base (Y above the surface, Z away from ridge centerline). So
|
||||
// after placement the inspector's Y / Z sliders nudge the vent off the
|
||||
// locked ridge without losing the slope-tracking base. X is the position
|
||||
// along the ridge — the snap re-clamps it to the segment's ridge span.
|
||||
const snap = resolveRidgeSnap(segment, node.position[0] ?? 0, 0)
|
||||
const ridgeX = snap ? snap.localX : (node.position[0] ?? 0)
|
||||
const baseZ = snap ? snap.localZ : 0
|
||||
const baseY = getSurfaceY(ridgeX, baseZ, segment) + RIDGE_LIFT
|
||||
const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment)
|
||||
// Clamp legacy stored Y (absolute peak height from earlier versions) so the
|
||||
// vent doesn't fly off when the field was an absolute Y instead of offset.
|
||||
const yOffset = Math.max(-2, Math.min(2, node.position[1] ?? 0))
|
||||
const ridgeY = baseY + yOffset
|
||||
const ridgeZ = baseZ + (node.position[2] ?? 0)
|
||||
const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0))
|
||||
const ridgeY = baseY + RIDGE_LIFT + yOffset
|
||||
|
||||
return (
|
||||
<group position={segPos} rotation-y={segRotY}>
|
||||
<group
|
||||
position={[ridgeX, ridgeY, ridgeZ]}
|
||||
ref={ref}
|
||||
rotation-y={node.rotation ?? 0}
|
||||
rotation-y={rotationY}
|
||||
visible={node.visible}
|
||||
>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={geometry}
|
||||
geometry={clippedGeometry ?? geometry}
|
||||
material={material}
|
||||
name="ridge-vent-surface"
|
||||
receiveShadow
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as THREE from 'three'
|
||||
import { resolveRidgeSnap } from '../shared/ridge-snap'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getRoofTopSurfaceY } from '../shared/roof-surface'
|
||||
import {
|
||||
clearRoofSurfacePlacementGuides,
|
||||
publishRoofSurfacePlacementGuides,
|
||||
@@ -27,11 +28,9 @@ import RidgeVentPreview from './preview'
|
||||
const worldPoint = new THREE.Vector3()
|
||||
|
||||
/**
|
||||
* Ridge vent placement tool. The cursor preview snaps to the ridge
|
||||
* (Z=0 in segment-local space) of whichever segment is under the
|
||||
* cursor, since the ridge vent's whole purpose is to sit on the peak.
|
||||
* Click anywhere on a segment commits the vent at the ridge directly
|
||||
* above that hit (X stays where the cursor was, Z snaps to 0).
|
||||
* Ridge vent placement tool. The cursor preview snaps to the nearest
|
||||
* ridge/break line of whichever segment is under the cursor, since the
|
||||
* cap needs to straddle a real roof crease.
|
||||
*/
|
||||
const RidgeVentTool = () => {
|
||||
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
||||
@@ -72,9 +71,9 @@ const RidgeVentTool = () => {
|
||||
)
|
||||
if (!hit) return
|
||||
|
||||
// Project the cursor onto the segment's ridge line (clamped to the
|
||||
// segment's ridge span). The preview then moves ALONG the ridge as the
|
||||
// cursor moves — never off it. Flat segments have no ridge: hide.
|
||||
// Project the cursor onto the nearest segment ridge/break line.
|
||||
// The preview then moves along that line as the cursor moves — never
|
||||
// off it. Flat segments have no ridge: hide.
|
||||
const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
|
||||
if (!snap) {
|
||||
setPreviewPos(null)
|
||||
@@ -84,7 +83,11 @@ const RidgeVentTool = () => {
|
||||
const segObj = sceneRegistry.nodes.get(hit.segment.id)
|
||||
let ridgeWorld: [number, number, number]
|
||||
if (segObj) {
|
||||
const ridgeLocal = new THREE.Vector3(snap.localX, hit.localY, snap.localZ)
|
||||
const ridgeLocal = new THREE.Vector3(
|
||||
snap.localX,
|
||||
getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment),
|
||||
snap.localZ,
|
||||
)
|
||||
segObj.updateWorldMatrix(true, false)
|
||||
ridgeLocal.applyMatrix4(segObj.matrixWorld)
|
||||
ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z]
|
||||
@@ -100,12 +103,16 @@ const RidgeVentTool = () => {
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation)
|
||||
setPreviewPos(worldToBuildingLocal(ridgeWorld[0], ridgeWorld[1], ridgeWorld[2]))
|
||||
publishRoofSurfacePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: hit.segment,
|
||||
center: [snap.localX, hit.localY, snap.localZ],
|
||||
center: [
|
||||
snap.localX,
|
||||
getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment),
|
||||
snap.localZ,
|
||||
],
|
||||
footprint: roofSurfaceFootprintFromNode(previewNode),
|
||||
mode: 'linear-edge',
|
||||
})
|
||||
@@ -129,7 +136,7 @@ const RidgeVentTool = () => {
|
||||
name: 'Ridge Vent',
|
||||
roofSegmentId: hit.segment.id,
|
||||
position: [snap.localX, 0, snap.localZ],
|
||||
rotation: 0,
|
||||
rotation: snap.rotation,
|
||||
})
|
||||
state.createNode(vent, hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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] }]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user