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
@@ -99,7 +99,6 @@ const MEP_ITEMS: MepItem[] = [
|
||||
{ id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' },
|
||||
{ id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' },
|
||||
{ id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' },
|
||||
{ id: 'pipe-trap', label: 'Trap', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-trap' },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -158,6 +157,7 @@ const MEP_TOOL_KINDS = new Set<string>([
|
||||
...MEP_ITEMS.map((item) => item.kind),
|
||||
'duct-fitting',
|
||||
'pipe-fitting',
|
||||
'pipe-trap',
|
||||
])
|
||||
|
||||
export function BuildTab() {
|
||||
@@ -465,7 +465,7 @@ export function BuildTab() {
|
||||
aria-hidden
|
||||
className="size-4 object-contain"
|
||||
height={16}
|
||||
src="/icons/dwv-pipes.png"
|
||||
src="/icons/dwv-pipes.webp"
|
||||
width={16}
|
||||
/>
|
||||
Add Trap
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -81,9 +81,6 @@
|
||||
"noNoninteractiveElementInteractions": "off",
|
||||
"useButtonType": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noShadow": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "info"
|
||||
}
|
||||
|
||||
@@ -194,6 +194,19 @@ export {
|
||||
resolveElevatorServiceLevelIds,
|
||||
resolveElevatorServiceLevels,
|
||||
} from './systems/elevator/elevator-service'
|
||||
export {
|
||||
getFenceCenterlineFrameAt,
|
||||
getFenceCenterlineLength,
|
||||
sampleFenceCenterline,
|
||||
} from './systems/fence/fence-centerline'
|
||||
export {
|
||||
getFenceControlHandle,
|
||||
getFenceSplineFrameAt,
|
||||
getFenceSplineLength,
|
||||
getTwoPointFenceCurveTangents,
|
||||
isSplineFence,
|
||||
sampleFenceSpline,
|
||||
} from './systems/fence/fence-spline'
|
||||
export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint'
|
||||
export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
|
||||
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
|
||||
|
||||
@@ -55,6 +55,17 @@ export type EditorApi = {
|
||||
* fences). No-ops for kinds without endpoints.
|
||||
*/
|
||||
engageEndpointMove: (node: AnyNode, endpoint: 'start' | 'end') => void
|
||||
/**
|
||||
* Engage drag of a spline control point (`path[index]`). Used by spline
|
||||
* fences to reshape their centerline. No-ops for kinds without a path.
|
||||
*/
|
||||
engageControlPointMove: (node: AnyNode, index: number) => void
|
||||
/**
|
||||
* Engage drag of a spline tangent handle (`path[index]`, which end). Used by
|
||||
* spline fences to bend the curve through one control point. No-ops for kinds
|
||||
* without tangents.
|
||||
*/
|
||||
engageTangentMove: (node: AnyNode, index: number, side: 'in' | 'out') => void
|
||||
}
|
||||
|
||||
export type HandlePortal = 'self' | 'parent' | 'grandparent'
|
||||
@@ -323,6 +334,11 @@ export type TapActionHandle<N = any> = {
|
||||
* drag, so the move tool's own preview / ticker feedback shows up.
|
||||
*/
|
||||
shape?: 'arrow' | 'corner-picker' | 'move-cross'
|
||||
/**
|
||||
* `shape: 'corner-picker'` only — render the disc and its outer ring as a
|
||||
* circle instead of the default hexagon.
|
||||
*/
|
||||
round?: boolean
|
||||
/**
|
||||
* Required when `shape: 'corner-picker'` — controls the dashed leader's
|
||||
* vertical extent. Pure callback so the descriptor doesn't need to
|
||||
|
||||
@@ -98,25 +98,53 @@ export { PipeFittingNode } from './nodes/pipe-fitting'
|
||||
export { PipeSegmentNode } from './nodes/pipe-segment'
|
||||
export { PipeTrapNode } from './nodes/pipe-trap'
|
||||
// Nodes
|
||||
export { RidgeVentNode } from './nodes/ridge-vent'
|
||||
export {
|
||||
createDefaultRidgeVentsForSegment,
|
||||
getRidgeVentLinesForSegment,
|
||||
hasAutoRidgeVentMetadata,
|
||||
isAutoRidgeVentEnabled,
|
||||
isDefaultRidgeVentNode,
|
||||
type RidgeVentLine,
|
||||
RidgeVentNode,
|
||||
} from './nodes/ridge-vent'
|
||||
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
|
||||
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
|
||||
export type {
|
||||
DutchRoofMetrics,
|
||||
RoofSegmentSurfaceMaterialRole,
|
||||
RoofSegmentSurfaceMaterialSpec,
|
||||
RoofSegmentVisibleTopBounds,
|
||||
SegmentSlopeFrame,
|
||||
} from './nodes/roof-segment'
|
||||
export {
|
||||
getActiveRoofHeight,
|
||||
getDutchRoofMetrics,
|
||||
getEffectiveSegmentSurfaceMaterial,
|
||||
getPitchFromActiveRoofHeight,
|
||||
getRoofSegmentSurfaceY,
|
||||
getRoofSegmentVisibleTopBounds,
|
||||
getSegmentSlopeFrame,
|
||||
hasSegmentMaterialOverride,
|
||||
MIN_ROOF_SEGMENT_TRIM_SPAN,
|
||||
normalizeRoofSegmentTrim,
|
||||
ROOF_SHAPE_DEFAULTS,
|
||||
RoofSegmentNode,
|
||||
RoofSegmentTrim,
|
||||
RoofType,
|
||||
} from './nodes/roof-segment'
|
||||
export type {
|
||||
DutchRoofShapeMetrics,
|
||||
RoofShapeFaceVertex,
|
||||
RoofShapeInsets,
|
||||
RoofShapeRatios,
|
||||
} from './nodes/roof-segment-shape'
|
||||
export {
|
||||
getDutchEndSlopeFaces,
|
||||
getDutchRoofShapeMetrics,
|
||||
getRoofModuleFaces,
|
||||
getRoofShapeInsets,
|
||||
getRoofShapeRatios,
|
||||
} from './nodes/roof-segment-shape'
|
||||
export type { RoofSegmentWallFace, RoofWallFaceId } from './nodes/roof-segment-walls'
|
||||
export {
|
||||
clampRectToRoofWallFace,
|
||||
|
||||
@@ -17,6 +17,19 @@ export const FenceNode = BaseNode.extend({
|
||||
slots: z.record(z.string(), z.string()).optional(),
|
||||
start: z.tuple([z.number(), z.number()]),
|
||||
end: z.tuple([z.number(), z.number()]),
|
||||
// Optional spline control points in level coordinate meters. When present
|
||||
// (>= 2 points) the fence centerline is a smooth Catmull-Rom curve through
|
||||
// these points and start/end/curveOffset no longer define the centerline.
|
||||
// start/end are kept in sync with the first/last path point so consumers
|
||||
// that read endpoints (handles, bbox, miter references) stay valid. Absent =
|
||||
// the straight or single-arc fence defined by start/end (+ curveOffset).
|
||||
path: z.array(z.tuple([z.number(), z.number()])).optional(),
|
||||
// Optional per-control-point tangent handles, parallel to `path` (same
|
||||
// length when present). Each entry is the OUT-handle offset vector [dx, dy]
|
||||
// from its path point, in level meters; the IN handle is its mirror so the
|
||||
// curve stays smooth through the point. `null` = use the automatic
|
||||
// Catmull-Rom tangent for that point. Only meaningful for spline fences.
|
||||
tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(),
|
||||
height: z.number().default(1.8),
|
||||
thickness: z.number().default(0.08),
|
||||
curveOffset: z.number().optional(),
|
||||
@@ -38,8 +51,10 @@ export const FenceNode = BaseNode.extend({
|
||||
dedent`
|
||||
Fence node - used to represent a fence segment in the building/site level coordinate system
|
||||
- start/end: fence endpoints in level coordinate system
|
||||
- path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them
|
||||
- tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent
|
||||
- height/thickness: overall fence dimensions in meters
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set)
|
||||
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
|
||||
- showInfill: whether to draw intermediate posts/slats between end posts
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
createDefaultRidgeVentsForSegment,
|
||||
getRidgeVentLinesForSegment,
|
||||
isAutoRidgeVentEnabled,
|
||||
isDefaultRidgeVentNode,
|
||||
RidgeVentNode,
|
||||
} from './ridge-vent'
|
||||
import {
|
||||
getDutchRoofMetrics,
|
||||
getRoofSegmentVisibleTopBounds,
|
||||
ROOF_SHAPE_DEFAULTS,
|
||||
RoofSegmentNode,
|
||||
} from './roof-segment'
|
||||
|
||||
describe('createDefaultRidgeVentsForSegment', () => {
|
||||
test('creates one shingled default ridge vent for gable roofs', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
})
|
||||
|
||||
const vents = createDefaultRidgeVentsForSegment(segment)
|
||||
|
||||
expect(vents).toHaveLength(1)
|
||||
expect(vents[0]?.name).toBe('Ridge Vent')
|
||||
expect(vents[0]?.style).toBe('shingled')
|
||||
expect(vents[0]?.roofSegmentId).toBe(segment.id)
|
||||
expect(isDefaultRidgeVentNode(vents[0], segment.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps generated gable ridge vents anchored to the untrimmed ridge', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
overhang: 0,
|
||||
wallThickness: 0,
|
||||
shingleThickness: 0,
|
||||
trim: { left: 1, right: 2, front: 0, back: 0 },
|
||||
})
|
||||
|
||||
const vents = createDefaultRidgeVentsForSegment(segment)
|
||||
|
||||
expect(vents).toHaveLength(1)
|
||||
expect(vents[0]?.length).toBeCloseTo(8)
|
||||
expect(vents[0]?.position[0]).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
test('creates top ridge plus four hip vents for rectangular hip roofs', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'hip',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
})
|
||||
|
||||
const vents = createDefaultRidgeVentsForSegment(segment)
|
||||
|
||||
expect(vents).toHaveLength(5)
|
||||
expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1)
|
||||
expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4)
|
||||
for (const vent of vents) {
|
||||
expect(vent.style).toBe('shingled')
|
||||
expect(vent.length).toBeGreaterThan(0.4)
|
||||
expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('omits the collapsed top ridge on square hip roofs', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'hip',
|
||||
width: 6,
|
||||
depth: 6,
|
||||
})
|
||||
|
||||
const vents = createDefaultRidgeVentsForSegment(segment)
|
||||
|
||||
expect(vents).toHaveLength(4)
|
||||
expect(vents.every((vent) => vent.name === 'Hip Ridge Vent')).toBe(true)
|
||||
})
|
||||
|
||||
test('creates a top ridge plus four hip vents for width-axis Dutch roofs', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'dutch',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
overhang: 0,
|
||||
wallThickness: 0,
|
||||
shingleThickness: 0,
|
||||
})
|
||||
|
||||
const vents = createDefaultRidgeVentsForSegment(segment)
|
||||
|
||||
expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1)
|
||||
expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4)
|
||||
// Width-axis Dutch ridge runs along X (constant Z = 0).
|
||||
const metrics = getDutchRoofMetrics(segment)
|
||||
const ridge = vents.find((vent) => vent.name === 'Ridge Vent')
|
||||
const frontRightHip = vents.find(
|
||||
(vent) =>
|
||||
vent.name === 'Hip Ridge Vent' &&
|
||||
(vent.position[0] ?? 0) > 0 &&
|
||||
(vent.position[2] ?? 0) > 0,
|
||||
)
|
||||
const expectedRakeReach = Math.min(
|
||||
segment.dutchGabletRake,
|
||||
Math.max(0, segment.width / 2 - metrics.waistHalfX) * 0.98,
|
||||
)
|
||||
expect(ridge?.position[2]).toBeCloseTo(0)
|
||||
expect(ridge?.length).toBeCloseTo((metrics.waistHalfX + expectedRakeReach) * 2, 2)
|
||||
expect(frontRightHip?.position[0]).toBeCloseTo((4 + 2.93) / 2, 2)
|
||||
expect(frontRightHip?.position[2]).toBeCloseTo((3 + 1.5) / 2, 2)
|
||||
for (const vent of vents) {
|
||||
expect(vent.style).toBe('shingled')
|
||||
expect(vent.length).toBeGreaterThan(0.4)
|
||||
expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('treats legacy segments with generated ridge vents as auto-enabled', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
})
|
||||
|
||||
const vents = createDefaultRidgeVentsForSegment(segment)
|
||||
const nodes = Object.fromEntries(vents.map((vent) => [vent.id, vent]))
|
||||
|
||||
expect(
|
||||
isAutoRidgeVentEnabled(
|
||||
{
|
||||
id: segment.id,
|
||||
children: vents.map((vent) => vent.id),
|
||||
metadata: {},
|
||||
},
|
||||
nodes,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('treats legacy preset-white ridge vents as generated defaults', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test' as never,
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
})
|
||||
const legacyVent = RidgeVentNode.parse({
|
||||
id: 'rvent_legacy' as never,
|
||||
roofSegmentId: segment.id,
|
||||
name: 'Ridge Vent',
|
||||
style: 'shingled',
|
||||
materialPreset: 'preset-white',
|
||||
position: [0, 0, 0],
|
||||
length: 8,
|
||||
})
|
||||
|
||||
expect(isDefaultRidgeVentNode(legacyVent, segment.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('creates a Z-oriented ridge plus four hip lines for depth-axis Dutch roofs', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'dutch',
|
||||
width: 6,
|
||||
depth: 8,
|
||||
overhang: 0,
|
||||
wallThickness: 0,
|
||||
shingleThickness: 0,
|
||||
})
|
||||
|
||||
const lines = getRidgeVentLinesForSegment(segment)
|
||||
const metrics = getDutchRoofMetrics(segment)
|
||||
const expectedRakeReach = Math.min(
|
||||
segment.dutchGabletRake,
|
||||
Math.max(0, segment.depth / 2 - metrics.waistHalfZ) * 0.98,
|
||||
)
|
||||
|
||||
const ridges = lines.filter((line) => line.name === 'Ridge Vent')
|
||||
const hips = lines.filter((line) => line.name === 'Hip Ridge Vent')
|
||||
expect(ridges).toHaveLength(1)
|
||||
expect(hips).toHaveLength(4)
|
||||
// Depth-axis Dutch ridge runs along Z (constant X = 0).
|
||||
expect(ridges[0]?.start[0]).toBeCloseTo(0)
|
||||
expect(ridges[0]?.end[0]).toBeCloseTo(0)
|
||||
expect(Math.abs(ridges[0]?.start[1] ?? 0)).toBeCloseTo(
|
||||
metrics.waistHalfZ + expectedRakeReach,
|
||||
2,
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps Dutch hip lines on the rendered arris when the roof has overhang', () => {
|
||||
// Overhang + shingle thickness expand the rendered roof; the eave corners
|
||||
// and the waist must share that expanded frame, otherwise the hip lines
|
||||
// tilt off the arris and the vents sink into the slope.
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'dutch',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
overhang: 0.5,
|
||||
wallThickness: 0.2,
|
||||
shingleThickness: 0.05,
|
||||
})
|
||||
|
||||
const lines = getRidgeVentLinesForSegment(segment)
|
||||
const hips = lines.filter((line) => line.name === 'Hip Ridge Vent')
|
||||
const ridge = lines.find((line) => line.name === 'Ridge Vent')
|
||||
expect(hips).toHaveLength(4)
|
||||
expect(ridge).toBeDefined()
|
||||
|
||||
const bounds = getRoofSegmentVisibleTopBounds(segment)
|
||||
const { axis, inset } = getDutchRoofMetrics(segment)
|
||||
const waistLengthRatio = segment.dutchWaistLengthRatio
|
||||
// width 8 >= depth 6 -> axis 'x': the ridge runs along X (waist scaled by
|
||||
// waistLengthRatio), and Z is the clean hipped axis (inset exactly).
|
||||
expect(axis).toBe('x')
|
||||
const halfWExpanded = bounds.maxX
|
||||
const halfDExpanded = bounds.maxZ
|
||||
const expectedWaistX = (halfWExpanded - inset) * waistLengthRatio
|
||||
const expectedWaistZ = halfDExpanded - inset
|
||||
const expectedRakeReach = Math.min(
|
||||
segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake,
|
||||
Math.max(0, halfWExpanded - expectedWaistX) * 0.98,
|
||||
)
|
||||
|
||||
for (const hip of hips) {
|
||||
const [ex, ez] = hip.start
|
||||
const [wx, wz] = hip.end
|
||||
// Eave end on an expanded-bounds corner; upper end at the rendered rake
|
||||
// termination where the lower slope starts, derived from the SAME
|
||||
// expanded frame (not the base-dim inner waist).
|
||||
expect(Math.abs(ex)).toBeCloseTo(halfWExpanded)
|
||||
expect(Math.abs(ez)).toBeCloseTo(halfDExpanded)
|
||||
expect(Math.abs(wx)).toBeCloseTo(expectedWaistX + expectedRakeReach)
|
||||
expect(Math.abs(wz)).toBeCloseTo(expectedWaistZ)
|
||||
}
|
||||
})
|
||||
|
||||
test('creates top ridge plus four upper hip vents plus four lower-slope vents for mansard roofs', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'mansard',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
})
|
||||
|
||||
const vents = createDefaultRidgeVentsForSegment(segment)
|
||||
|
||||
expect(vents).toHaveLength(9)
|
||||
expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1)
|
||||
expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4)
|
||||
expect(vents.filter((vent) => vent.name === 'Slope Ridge Vent')).toHaveLength(4)
|
||||
expect(vents.find((vent) => vent.name === 'Ridge Vent')?.length).toBeLessThan(segment.width)
|
||||
const slopeVents = vents.filter((vent) => vent.name === 'Slope Ridge Vent')
|
||||
const slopeRotations = slopeVents.map((vent) => Math.abs(vent.rotation))
|
||||
expect(slopeRotations.every((rotation) => rotation > 0.1)).toBe(true)
|
||||
expect(slopeRotations.every((rotation) => Math.abs(rotation - Math.PI / 2) > 0.1)).toBe(true)
|
||||
expect(
|
||||
slopeVents.every(
|
||||
(vent) =>
|
||||
Math.abs(vent.position[0]) > segment.width / 2 - 0.8 &&
|
||||
Math.abs(vent.position[2]) > segment.depth / 2 - 0.8,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
new Set(
|
||||
slopeVents.map((vent) => `${Math.sign(vent.position[0])},${Math.sign(vent.position[2])}`),
|
||||
).size,
|
||||
).toBe(4)
|
||||
for (const vent of vents) {
|
||||
expect(vent.style).toBe('shingled')
|
||||
expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,15 +2,50 @@ import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
import {
|
||||
getDutchRoofMetrics,
|
||||
getRoofSegmentVisibleTopBounds,
|
||||
ROOF_SHAPE_DEFAULTS,
|
||||
type RoofSegmentNode,
|
||||
type RoofSegmentTrim,
|
||||
} from './roof-segment'
|
||||
|
||||
const MIN_DEFAULT_RIDGE_VENT_LENGTH_M = 0.4
|
||||
const DEFAULT_RIDGE_VENT_GENERATOR = 'default-ridge-vent'
|
||||
const AUTO_RIDGE_VENT_METADATA_KEY = 'autoRidgeVent'
|
||||
const LEGACY_DEFAULT_RIDGE_VENT_PRESET = 'preset-white'
|
||||
const UNTRIMMED_RIDGE_VENT_BOUNDS_TRIM: RoofSegmentTrim = {
|
||||
left: 0,
|
||||
right: 0,
|
||||
front: 0,
|
||||
back: 0,
|
||||
frontLeft: 0,
|
||||
frontRight: 0,
|
||||
backLeft: 0,
|
||||
backRight: 0,
|
||||
frontLeftX: 0,
|
||||
frontLeftZ: 0,
|
||||
frontRightX: 0,
|
||||
frontRightZ: 0,
|
||||
backLeftX: 0,
|
||||
backLeftZ: 0,
|
||||
backRightX: 0,
|
||||
backRightZ: 0,
|
||||
}
|
||||
|
||||
export type RidgeVentLine = {
|
||||
name: string
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
}
|
||||
|
||||
export const RidgeVentNode = BaseNode.extend({
|
||||
id: objectId('rvent'),
|
||||
type: nodeType('ridge-vent'),
|
||||
|
||||
material: MaterialSchema.optional(),
|
||||
// See note on box-vent: default to white so the paint inspector
|
||||
// reflects the current visual state instead of "no material".
|
||||
materialPreset: z.string().default('preset-white'),
|
||||
// Unpainted ridge vents inherit the roof top material in the renderer.
|
||||
materialPreset: z.string().optional(),
|
||||
|
||||
roofSegmentId: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
@@ -37,3 +72,236 @@ export const RidgeVentNode = BaseNode.extend({
|
||||
)
|
||||
|
||||
export type RidgeVentNode = z.infer<typeof RidgeVentNode>
|
||||
|
||||
export function getRidgeVentLinesForSegment(segment: RoofSegmentNode): RidgeVentLine[] {
|
||||
const bounds = getRoofSegmentVisibleTopBounds({
|
||||
...segment,
|
||||
trim: UNTRIMMED_RIDGE_VENT_BOUNDS_TRIM,
|
||||
})
|
||||
const { width, depth, minX, maxX, minZ, maxZ } = bounds
|
||||
if (segment.roofType === 'flat' || segment.roofType === 'shed') return []
|
||||
|
||||
const halfW = width / 2
|
||||
const halfD = depth / 2
|
||||
const ridgeZVisible = minZ <= 0 && maxZ >= 0
|
||||
const ridgeXVisible = minX <= 0 && maxX >= 0
|
||||
|
||||
if (segment.roofType === 'mansard') {
|
||||
const inset = Math.min(
|
||||
Math.min(width, depth) *
|
||||
(segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio),
|
||||
Math.max(0, Math.min(width, depth) / 2 - 0.01),
|
||||
)
|
||||
const shoulderMinX = minX + inset
|
||||
const shoulderMaxX = maxX - inset
|
||||
const shoulderMinZ = minZ + inset
|
||||
const shoulderMaxZ = maxZ - inset
|
||||
const topW = Math.max(0, shoulderMaxX - shoulderMinX)
|
||||
const topD = Math.max(0, shoulderMaxZ - shoulderMinZ)
|
||||
const lowerSlopeLines: RidgeVentLine[] = [
|
||||
{
|
||||
name: 'Slope Ridge Vent',
|
||||
start: [shoulderMinX, shoulderMaxZ],
|
||||
end: [minX, maxZ],
|
||||
},
|
||||
{
|
||||
name: 'Slope Ridge Vent',
|
||||
start: [shoulderMaxX, shoulderMaxZ],
|
||||
end: [maxX, maxZ],
|
||||
},
|
||||
{
|
||||
name: 'Slope Ridge Vent',
|
||||
start: [shoulderMaxX, shoulderMinZ],
|
||||
end: [maxX, minZ],
|
||||
},
|
||||
{
|
||||
name: 'Slope Ridge Vent',
|
||||
start: [shoulderMinX, shoulderMinZ],
|
||||
end: [minX, minZ],
|
||||
},
|
||||
]
|
||||
|
||||
if (topW >= topD) {
|
||||
const leftRidge: [number, number] = [shoulderMinX + topD / 2, 0]
|
||||
const rightRidge: [number, number] = [shoulderMaxX - topD / 2, 0]
|
||||
return [
|
||||
...(ridgeZVisible ? [{ name: 'Ridge Vent', start: leftRidge, end: rightRidge }] : []),
|
||||
{ name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMaxZ], end: leftRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMinZ], end: leftRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMaxZ], end: rightRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMinZ], end: rightRidge },
|
||||
...lowerSlopeLines,
|
||||
]
|
||||
}
|
||||
|
||||
const frontRidge: [number, number] = [0, shoulderMaxZ - topW / 2]
|
||||
const backRidge: [number, number] = [0, shoulderMinZ + topW / 2]
|
||||
return [
|
||||
...(ridgeXVisible ? [{ name: 'Ridge Vent', start: frontRidge, end: backRidge }] : []),
|
||||
{ name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMaxZ], end: frontRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMaxZ], end: frontRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMinZ], end: backRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMinZ], end: backRidge },
|
||||
...lowerSlopeLines,
|
||||
]
|
||||
}
|
||||
|
||||
if (segment.roofType === 'dutch') {
|
||||
const { axis, inset } = getDutchRoofMetrics(segment)
|
||||
// The rendered hip arris and waist come from the EXPANDED (overhang +
|
||||
// shingle) rectangle — the same dims the eave corners below use — while
|
||||
// the inset is base-derived (matches getDutchRoofMetrics / the brush
|
||||
// builder). Deriving the waist from `getDutchRoofMetrics`' own base-dim
|
||||
// half-spans would tilt the hip lines off the arris on any roof with
|
||||
// overhang, sinking the vents into the slope. Mirror the mansard branch:
|
||||
// one coordinate system (expanded bounds) for both eave and waist.
|
||||
const waistLengthRatio =
|
||||
segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio
|
||||
const waistHalfX =
|
||||
axis === 'x' ? Math.max(0, (halfW - inset) * waistLengthRatio) : Math.max(0, halfW - inset)
|
||||
const waistHalfZ =
|
||||
axis === 'x' ? Math.max(0, halfD - inset) : Math.max(0, (halfD - inset) * waistLengthRatio)
|
||||
if (!(waistHalfX > 0.001 && waistHalfZ > 0.001)) return []
|
||||
|
||||
// Dutch lower hip vents should terminate where the lower slope actually
|
||||
// meets the gablet rake, not at the inner waist line of the upper gable
|
||||
// triangle. Mirror the rendered roof shell's "outer waist" cap with the
|
||||
// same expanded-bounds frame we use for the eave corners above.
|
||||
const rake = segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake
|
||||
const rakeReach =
|
||||
axis === 'x'
|
||||
? Math.min(Math.max(0, rake), Math.max(0, halfW - waistHalfX) * 0.98)
|
||||
: Math.min(Math.max(0, rake), Math.max(0, halfD - waistHalfZ) * 0.98)
|
||||
const rakeEndHalfX = axis === 'x' ? waistHalfX + rakeReach : waistHalfX
|
||||
const rakeEndHalfZ = axis === 'x' ? waistHalfZ : waistHalfZ + rakeReach
|
||||
|
||||
const mainRidgeVisible = axis === 'x' ? ridgeZVisible : ridgeXVisible
|
||||
const ridgeStart: [number, number] = axis === 'x' ? [-rakeEndHalfX, 0] : [0, rakeEndHalfZ]
|
||||
const ridgeEnd: [number, number] = axis === 'x' ? [rakeEndHalfX, 0] : [0, -rakeEndHalfZ]
|
||||
return [
|
||||
...(mainRidgeVisible ? [{ name: 'Ridge Vent', start: ridgeStart, end: ridgeEnd }] : []),
|
||||
{ name: 'Hip Ridge Vent', start: [minX, maxZ], end: [-rakeEndHalfX, rakeEndHalfZ] },
|
||||
{ name: 'Hip Ridge Vent', start: [maxX, maxZ], end: [rakeEndHalfX, rakeEndHalfZ] },
|
||||
{ name: 'Hip Ridge Vent', start: [maxX, minZ], end: [rakeEndHalfX, -rakeEndHalfZ] },
|
||||
{ name: 'Hip Ridge Vent', start: [minX, minZ], end: [-rakeEndHalfX, -rakeEndHalfZ] },
|
||||
]
|
||||
}
|
||||
|
||||
if (segment.roofType !== 'hip') {
|
||||
if (!ridgeZVisible) return []
|
||||
return [
|
||||
{
|
||||
name: 'Ridge Vent',
|
||||
start: [minX, 0],
|
||||
end: [maxX, 0],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (width >= depth) {
|
||||
const leftRidge: [number, number] = [minX + halfD, 0]
|
||||
const rightRidge: [number, number] = [maxX - halfD, 0]
|
||||
return [
|
||||
...(ridgeZVisible ? [{ name: 'Ridge Vent', start: leftRidge, end: rightRidge }] : []),
|
||||
{ name: 'Hip Ridge Vent', start: [minX, maxZ], end: leftRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [minX, minZ], end: leftRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [maxX, maxZ], end: rightRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [maxX, minZ], end: rightRidge },
|
||||
]
|
||||
}
|
||||
|
||||
const frontRidge: [number, number] = [0, maxZ - halfW]
|
||||
const backRidge: [number, number] = [0, minZ + halfW]
|
||||
return [
|
||||
...(ridgeXVisible ? [{ name: 'Ridge Vent', start: frontRidge, end: backRidge }] : []),
|
||||
{ name: 'Hip Ridge Vent', start: [minX, maxZ], end: frontRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [maxX, maxZ], end: frontRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [minX, minZ], end: backRidge },
|
||||
{ name: 'Hip Ridge Vent', start: [maxX, minZ], end: backRidge },
|
||||
]
|
||||
}
|
||||
|
||||
function getLineYaw(start: [number, number], end: [number, number]): number {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
return Math.atan2(-dz, dx)
|
||||
}
|
||||
|
||||
export function createDefaultRidgeVentsForSegment(segment: RoofSegmentNode): RidgeVentNode[] {
|
||||
return getRidgeVentLinesForSegment(segment)
|
||||
.map((line) => {
|
||||
const length = Math.hypot(line.end[0] - line.start[0], line.end[1] - line.start[1])
|
||||
if (length < MIN_DEFAULT_RIDGE_VENT_LENGTH_M) return null
|
||||
|
||||
return RidgeVentNode.parse({
|
||||
name: line.name,
|
||||
roofSegmentId: segment.id,
|
||||
position: [(line.start[0] + line.end[0]) / 2, 0, (line.start[1] + line.end[1]) / 2],
|
||||
rotation: getLineYaw(line.start, line.end),
|
||||
length,
|
||||
style: 'shingled',
|
||||
metadata: { generatedBy: DEFAULT_RIDGE_VENT_GENERATOR },
|
||||
})
|
||||
})
|
||||
.filter((vent): vent is RidgeVentNode => vent !== null)
|
||||
}
|
||||
|
||||
export function isDefaultRidgeVentNode(
|
||||
node: unknown,
|
||||
roofSegmentId?: RoofSegmentNode['id'],
|
||||
): node is RidgeVentNode {
|
||||
const parsed = RidgeVentNode.safeParse(node)
|
||||
if (!parsed.success) return false
|
||||
const vent = parsed.data
|
||||
if (roofSegmentId && vent.roofSegmentId !== roofSegmentId) return false
|
||||
const metadata = vent.metadata
|
||||
if (
|
||||
typeof metadata === 'object' &&
|
||||
metadata !== null &&
|
||||
(metadata as Record<string, unknown>).generatedBy === DEFAULT_RIDGE_VENT_GENERATOR
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const hasDefaultName =
|
||||
vent.name === 'Ridge Vent' ||
|
||||
vent.name === 'Hip Ridge Vent' ||
|
||||
vent.name === 'Shoulder Ridge Vent' ||
|
||||
vent.name === 'Slope Ridge Vent'
|
||||
return (
|
||||
hasDefaultName &&
|
||||
vent.style === 'shingled' &&
|
||||
vent.material === undefined &&
|
||||
vent.materialPreset === LEGACY_DEFAULT_RIDGE_VENT_PRESET
|
||||
)
|
||||
}
|
||||
|
||||
function metadataRecord(metadata: unknown): Record<string, unknown> {
|
||||
if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) {
|
||||
return metadata as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function hasAutoRidgeVentMetadata(
|
||||
segment: Pick<RoofSegmentNode, 'metadata'>,
|
||||
): segment is Pick<RoofSegmentNode, 'metadata'> & {
|
||||
metadata: Record<string, unknown> & { autoRidgeVent: boolean }
|
||||
} {
|
||||
return typeof metadataRecord(segment.metadata)[AUTO_RIDGE_VENT_METADATA_KEY] === 'boolean'
|
||||
}
|
||||
|
||||
export function isAutoRidgeVentEnabled(
|
||||
segment: Pick<RoofSegmentNode, 'id' | 'children' | 'metadata'>,
|
||||
nodes?: Record<string, unknown>,
|
||||
): boolean {
|
||||
const metadataValue = metadataRecord(segment.metadata)[AUTO_RIDGE_VENT_METADATA_KEY]
|
||||
if (typeof metadataValue === 'boolean') {
|
||||
return metadataValue
|
||||
}
|
||||
|
||||
if (!nodes) return false
|
||||
return (segment.children ?? []).some((childId) =>
|
||||
isDefaultRidgeVentNode(nodes[childId], segment.id),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
getDutchEndSlopeFaces,
|
||||
getDutchRoofShapeMetrics,
|
||||
getRoofModuleFaces,
|
||||
getRoofShapeRatios,
|
||||
} from './roof-segment-shape'
|
||||
|
||||
describe('roof segment shape', () => {
|
||||
test('dutch shell is built as one complete non-duplicated face set', () => {
|
||||
const wh = 3
|
||||
const rh = 2
|
||||
const faces = getRoofModuleFaces({
|
||||
type: 'dutch',
|
||||
w: 8,
|
||||
d: 6,
|
||||
wh,
|
||||
rh,
|
||||
baseY: 0,
|
||||
insets: { dutchI: 1.5 },
|
||||
baseW: 8,
|
||||
baseD: 6,
|
||||
tanTheta: 1,
|
||||
shapeRatios: getRoofShapeRatios({
|
||||
dutchHipWidthRatio: 0.25,
|
||||
dutchHipHeightRatio: 0.5,
|
||||
dutchWaistLengthRatio: 1,
|
||||
dutchGabletRake: 0.25,
|
||||
}),
|
||||
})
|
||||
const peakY = wh + rh
|
||||
const peakFaces = faces.filter((face) =>
|
||||
face.some((vertex) => Math.abs(vertex.y - peakY) < 1e-6),
|
||||
)
|
||||
const signatures = new Set(
|
||||
faces.map((face) =>
|
||||
face
|
||||
.map((vertex) => `${vertex.x.toFixed(3)},${vertex.y.toFixed(3)},${vertex.z.toFixed(3)}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
),
|
||||
)
|
||||
|
||||
expect(faces).toHaveLength(13)
|
||||
expect(peakFaces).toHaveLength(4)
|
||||
expect(signatures.size).toBe(faces.length)
|
||||
|
||||
const lowerRightHip = faces.find((face) =>
|
||||
face.some(
|
||||
(vertex) =>
|
||||
Math.abs(vertex.x - 2.75) < 1e-6 &&
|
||||
Math.abs(vertex.y - 4) < 1e-6 &&
|
||||
Math.abs(vertex.z - 1.5) < 1e-6,
|
||||
),
|
||||
)
|
||||
const gableTriangle = faces.find(
|
||||
(face) =>
|
||||
face.length === 3 &&
|
||||
face.some((vertex) => vertex.x === 2.5 && vertex.y === 5) &&
|
||||
face.some((vertex) => vertex.x === 2.5 && vertex.z === 1.5),
|
||||
)
|
||||
|
||||
expect(lowerRightHip).toBeDefined()
|
||||
expect(gableTriangle?.some((vertex) => vertex.x === 2.75)).toBe(false)
|
||||
})
|
||||
|
||||
test('dutch depth-axis shell rotates the upper gable correctly', () => {
|
||||
const faces = getRoofModuleFaces({
|
||||
type: 'dutch',
|
||||
w: 6,
|
||||
d: 8,
|
||||
wh: 3,
|
||||
rh: 2,
|
||||
baseY: 0,
|
||||
insets: { dutchI: 1.5 },
|
||||
baseW: 6,
|
||||
baseD: 8,
|
||||
tanTheta: 1,
|
||||
shapeRatios: getRoofShapeRatios({
|
||||
dutchHipWidthRatio: 0.25,
|
||||
dutchHipHeightRatio: 0.5,
|
||||
dutchWaistLengthRatio: 1,
|
||||
dutchGabletRake: 0.25,
|
||||
}),
|
||||
})
|
||||
|
||||
const peakFaces = faces.filter((face) => face.some((vertex) => vertex.y === 5))
|
||||
const ridgeOnlyOnZAxis = peakFaces
|
||||
.flat()
|
||||
.filter((vertex) => vertex.y === 5)
|
||||
.every((vertex) => vertex.x === 0)
|
||||
|
||||
expect(peakFaces).toHaveLength(4)
|
||||
expect(ridgeOnlyOnZAxis).toBe(true)
|
||||
})
|
||||
|
||||
test('dutch end hip slope extends inward until it meets the gable triangle', () => {
|
||||
const ratios = getRoofShapeRatios({
|
||||
dutchHipWidthRatio: 0.25,
|
||||
dutchHipHeightRatio: 0.5,
|
||||
dutchWaistLengthRatio: 1,
|
||||
dutchGabletRake: 0.75,
|
||||
})
|
||||
const metrics = getDutchRoofShapeMetrics({
|
||||
w: 8,
|
||||
d: 6,
|
||||
wh: 3,
|
||||
rh: 2,
|
||||
dutchI: 1.5,
|
||||
baseW: 8,
|
||||
baseD: 6,
|
||||
shapeRatios: ratios,
|
||||
})
|
||||
const faces = getRoofModuleFaces({
|
||||
type: 'dutch',
|
||||
w: 8,
|
||||
d: 6,
|
||||
wh: 3,
|
||||
rh: 2,
|
||||
baseY: 0,
|
||||
insets: { dutchI: 1.5 },
|
||||
baseW: 8,
|
||||
baseD: 6,
|
||||
tanTheta: 1,
|
||||
shapeRatios: ratios,
|
||||
dutchTopRakeThickness: 0.21,
|
||||
})
|
||||
|
||||
expect(metrics?.innerWaistHalfX).toBe(2.5)
|
||||
expect(metrics?.outerWaistHalfX).toBe(3.25)
|
||||
|
||||
// The end slope now clips against the rake's lower inner edge and is
|
||||
// reprojected back onto the end-slope plane, so the shorter top edge stays
|
||||
// planar instead of twisting.
|
||||
const hipTopFace = faces.find(
|
||||
(face) =>
|
||||
face.some(
|
||||
(vertex) =>
|
||||
Math.abs(vertex.x - 4) < 1e-6 &&
|
||||
Math.abs(vertex.y - 3) < 1e-6 &&
|
||||
Math.abs(vertex.z - 3) < 1e-6,
|
||||
) &&
|
||||
face.some(
|
||||
(vertex) =>
|
||||
Math.abs(vertex.x - 3.0325) < 1e-6 &&
|
||||
Math.abs(vertex.y - 4.29) < 1e-6 &&
|
||||
Math.abs(vertex.z - 0.75) < 1e-6,
|
||||
) &&
|
||||
face.some(
|
||||
(vertex) =>
|
||||
Math.abs(vertex.x - 3.0325) < 1e-6 &&
|
||||
Math.abs(vertex.y - 4.29) < 1e-6 &&
|
||||
Math.abs(vertex.z + 0.75) < 1e-6,
|
||||
),
|
||||
)
|
||||
const gableTriangle = faces.find(
|
||||
(face) =>
|
||||
face.length === 3 &&
|
||||
face.some((vertex) => vertex.x === 2.5 && vertex.y === 5) &&
|
||||
face.some((vertex) => vertex.x === 2.5 && vertex.z === 1.5),
|
||||
)
|
||||
|
||||
expect(hipTopFace).toBeDefined()
|
||||
expect(gableTriangle?.some((vertex) => vertex.x === 3.25)).toBe(false)
|
||||
})
|
||||
|
||||
test('excludeDutchEndSlopes pulls the end slopes out into getDutchEndSlopeFaces', () => {
|
||||
const ratios = getRoofShapeRatios({
|
||||
dutchHipWidthRatio: 0.25,
|
||||
dutchHipHeightRatio: 0.5,
|
||||
dutchWaistLengthRatio: 1,
|
||||
dutchGabletRake: 0.75,
|
||||
})
|
||||
const args = {
|
||||
type: 'dutch' as const,
|
||||
w: 8,
|
||||
d: 6,
|
||||
wh: 3,
|
||||
rh: 2,
|
||||
baseY: 0,
|
||||
insets: { dutchI: 1.5 },
|
||||
baseW: 8,
|
||||
baseD: 6,
|
||||
tanTheta: 1,
|
||||
shapeRatios: ratios,
|
||||
dutchTopRakeThickness: 0.21,
|
||||
}
|
||||
|
||||
const full = getRoofModuleFaces(args)
|
||||
const shell = getRoofModuleFaces({ ...args, excludeDutchEndSlopes: true })
|
||||
const endSlopes = getDutchEndSlopeFaces({
|
||||
w: 8,
|
||||
d: 6,
|
||||
wh: 3,
|
||||
rh: 2,
|
||||
insets: { dutchI: 1.5 },
|
||||
baseW: 8,
|
||||
baseD: 6,
|
||||
shapeRatios: ratios,
|
||||
dutchTopRakeThickness: 0.21,
|
||||
})
|
||||
|
||||
// The two end slopes leave the shell and reappear in the standalone set.
|
||||
expect(shell).toHaveLength(full.length - 2)
|
||||
expect(endSlopes).toHaveLength(2)
|
||||
|
||||
// The standalone end slopes are 6-point polygons whose shorter top edge is
|
||||
// clipped to the rake's lower inner edge and then kept on the end-slope
|
||||
// plane.
|
||||
const endIsEndSlope = endSlopes.every(
|
||||
(face) =>
|
||||
face.length === 6 &&
|
||||
face.some((vertex) => Math.abs(Math.abs(vertex.x) - 3.25) < 1e-6) &&
|
||||
face.some((vertex) => Math.abs(Math.abs(vertex.x) - 3.0325) < 1e-6) &&
|
||||
face.some((vertex) => Math.abs(Math.abs(vertex.x) - 4) < 1e-6),
|
||||
)
|
||||
expect(endIsEndSlope).toBe(true)
|
||||
|
||||
// The removed faces are exactly the end slopes — the shell keeps every
|
||||
// other face the full module produced.
|
||||
const signature = (face: { x: number; y: number; z: number }[]) =>
|
||||
face
|
||||
.map((vertex) => `${vertex.x.toFixed(3)},${vertex.y.toFixed(3)},${vertex.z.toFixed(3)}`)
|
||||
.join('|')
|
||||
const shellSigs = new Set(shell.map(signature))
|
||||
const removed = full.filter((face) => !shellSigs.has(signature(face)))
|
||||
expect(removed).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,441 @@
|
||||
import { ROOF_SHAPE_DEFAULTS, type RoofType } from './roof-segment'
|
||||
|
||||
export type RoofShapeFaceVertex = {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}
|
||||
|
||||
export type RoofShapeInsets = {
|
||||
iF?: number
|
||||
iB?: number
|
||||
iL?: number
|
||||
iR?: number
|
||||
dutchI?: number
|
||||
}
|
||||
|
||||
export type RoofShapeRatios = {
|
||||
gambrelLowerWidthRatio: number
|
||||
mansardSteepWidthRatio: number
|
||||
dutchHipWidthRatio: number
|
||||
dutchHipHeightRatio: number
|
||||
dutchWaistLengthRatio: number
|
||||
dutchGabletRake: number
|
||||
}
|
||||
|
||||
export function getRoofShapeRatios(input: {
|
||||
gambrelLowerWidthRatio?: number
|
||||
mansardSteepWidthRatio?: number
|
||||
dutchHipWidthRatio?: number
|
||||
dutchHipHeightRatio?: number
|
||||
dutchWaistLengthRatio?: number
|
||||
dutchGabletRake?: number
|
||||
}): RoofShapeRatios {
|
||||
return {
|
||||
gambrelLowerWidthRatio:
|
||||
input.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
|
||||
mansardSteepWidthRatio:
|
||||
input.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio,
|
||||
dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
|
||||
dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
|
||||
dutchWaistLengthRatio: input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
|
||||
dutchGabletRake: input.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake,
|
||||
}
|
||||
}
|
||||
|
||||
export type DutchRoofShapeMetrics = {
|
||||
axis: 'width' | 'depth'
|
||||
inset: number
|
||||
middleHeight: number
|
||||
peakHeight: number
|
||||
rakeReach: number
|
||||
innerWaistHalfX: number
|
||||
innerWaistHalfZ: number
|
||||
outerWaistHalfX: number
|
||||
outerWaistHalfZ: number
|
||||
}
|
||||
|
||||
export function getDutchRoofShapeMetrics(input: {
|
||||
w: number
|
||||
d: number
|
||||
wh: number
|
||||
rh: number
|
||||
dutchI?: number
|
||||
baseW: number
|
||||
baseD: number
|
||||
shapeRatios: RoofShapeRatios
|
||||
}): DutchRoofShapeMetrics | null {
|
||||
const fallbackInset = Math.min(input.baseW, input.baseD) * input.shapeRatios.dutchHipWidthRatio
|
||||
const maxI = Math.max(0, Math.min(input.w, input.d) / 2 - 0.005)
|
||||
const inset = Math.min(Math.max(0, input.dutchI ?? fallbackInset), maxI)
|
||||
const peakHeight = input.wh + Math.max(0.001, input.rh)
|
||||
const middleHeight = input.wh + input.rh * input.shapeRatios.dutchHipHeightRatio
|
||||
if (!(inset > 0.001) || !(peakHeight > middleHeight + 0.001)) return null
|
||||
|
||||
if (input.w >= input.d) {
|
||||
const innerWaistHalfX = Math.max(
|
||||
0,
|
||||
(input.w / 2 - inset) * input.shapeRatios.dutchWaistLengthRatio,
|
||||
)
|
||||
const innerWaistHalfZ = Math.max(0, input.d / 2 - inset)
|
||||
if (!(innerWaistHalfX > 0.001 && innerWaistHalfZ > 0.001)) return null
|
||||
|
||||
const rakeReach = Math.min(
|
||||
Math.max(0, input.shapeRatios.dutchGabletRake),
|
||||
Math.max(0, input.w / 2 - innerWaistHalfX) * 0.98,
|
||||
)
|
||||
|
||||
return {
|
||||
axis: 'width',
|
||||
inset,
|
||||
middleHeight,
|
||||
peakHeight,
|
||||
rakeReach,
|
||||
innerWaistHalfX,
|
||||
innerWaistHalfZ,
|
||||
outerWaistHalfX: innerWaistHalfX + rakeReach,
|
||||
outerWaistHalfZ: innerWaistHalfZ,
|
||||
}
|
||||
}
|
||||
|
||||
const innerWaistHalfX = Math.max(0, input.w / 2 - inset)
|
||||
const innerWaistHalfZ = Math.max(
|
||||
0,
|
||||
(input.d / 2 - inset) * input.shapeRatios.dutchWaistLengthRatio,
|
||||
)
|
||||
if (!(innerWaistHalfX > 0.001 && innerWaistHalfZ > 0.001)) return null
|
||||
|
||||
const rakeReach = Math.min(
|
||||
Math.max(0, input.shapeRatios.dutchGabletRake),
|
||||
Math.max(0, input.d / 2 - innerWaistHalfZ) * 0.98,
|
||||
)
|
||||
|
||||
return {
|
||||
axis: 'depth',
|
||||
inset,
|
||||
middleHeight,
|
||||
peakHeight,
|
||||
rakeReach,
|
||||
innerWaistHalfX,
|
||||
innerWaistHalfZ,
|
||||
outerWaistHalfX: innerWaistHalfX,
|
||||
outerWaistHalfZ: innerWaistHalfZ + rakeReach,
|
||||
}
|
||||
}
|
||||
|
||||
export function getRoofShapeInsets(input: {
|
||||
roofType: RoofType
|
||||
width: number
|
||||
depth: number
|
||||
wh: number
|
||||
baseY: number
|
||||
isVoid: boolean
|
||||
brushW: number
|
||||
brushD: number
|
||||
tanTheta: number
|
||||
shingleThickness: number
|
||||
dutchHipWidthRatio: number
|
||||
}): RoofShapeInsets {
|
||||
let inset = (input.wh - input.baseY) * input.tanTheta
|
||||
const maxSafeInset = Math.min(input.brushW, input.brushD) / 2 - 0.005
|
||||
if (inset > maxSafeInset) inset = maxSafeInset
|
||||
|
||||
let iF = 0
|
||||
let iB = 0
|
||||
let iL = 0
|
||||
let iR = 0
|
||||
if (input.roofType === 'hip' || input.roofType === 'mansard' || input.roofType === 'dutch') {
|
||||
iF = inset
|
||||
iB = inset
|
||||
iL = inset
|
||||
iR = inset
|
||||
} else if (input.roofType === 'gable' || input.roofType === 'gambrel') {
|
||||
iF = inset
|
||||
iB = inset
|
||||
} else if (input.roofType === 'shed') {
|
||||
iF = inset
|
||||
}
|
||||
|
||||
let dutchI = Math.min(input.width, input.depth) * input.dutchHipWidthRatio
|
||||
if (input.isVoid) dutchI += input.shingleThickness
|
||||
return { iF, iB, iL, iR, dutchI }
|
||||
}
|
||||
|
||||
export function getDutchEndSlopeFaces(input: {
|
||||
w: number
|
||||
d: number
|
||||
wh: number
|
||||
rh: number
|
||||
insets: RoofShapeInsets
|
||||
baseW: number
|
||||
baseD: number
|
||||
shapeRatios: RoofShapeRatios
|
||||
dutchTopRakeThickness?: number
|
||||
}): RoofShapeFaceVertex[][] {
|
||||
const dutch = getDutchRoofShapeMetrics({
|
||||
w: input.w,
|
||||
d: input.d,
|
||||
wh: input.wh,
|
||||
rh: input.rh,
|
||||
dutchI: input.insets.dutchI,
|
||||
baseW: input.baseW,
|
||||
baseD: input.baseD,
|
||||
shapeRatios: input.shapeRatios,
|
||||
})
|
||||
if (!dutch) return []
|
||||
|
||||
const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z })
|
||||
const e1 = v(-input.w / 2, input.wh, input.d / 2)
|
||||
const e2 = v(input.w / 2, input.wh, input.d / 2)
|
||||
const e3 = v(input.w / 2, input.wh, -input.d / 2)
|
||||
const e4 = v(-input.w / 2, input.wh, -input.d / 2)
|
||||
|
||||
// The hip end slope is constructed only up to the outer waist (rake) line.
|
||||
// Extend its top edge inward along the same hip rulings until it reaches the
|
||||
// gablet's inner triangle (the inner waist line), continuing each
|
||||
// eave→outer-waist ridge line so the face stays planar and the pitch is
|
||||
// unchanged — the edge climbs past middleHeight and meets the gablet face.
|
||||
const lerp = (a: RoofShapeFaceVertex, b: RoofShapeFaceVertex, t: number): RoofShapeFaceVertex =>
|
||||
v(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t)
|
||||
const lowerAlongY = (point: RoofShapeFaceVertex): RoofShapeFaceVertex =>
|
||||
v(point.x, point.y - Math.max(0, input.dutchTopRakeThickness ?? 0), point.z)
|
||||
|
||||
if (dutch.axis === 'width') {
|
||||
const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
const r1 = v(-dutch.innerWaistHalfX, input.wh + input.rh, 0)
|
||||
const r2 = v(dutch.innerWaistHalfX, input.wh + input.rh, 0)
|
||||
const projectWidthAxisPointToEndSlope = (
|
||||
point: RoofShapeFaceVertex,
|
||||
side: 1 | -1,
|
||||
): RoofShapeFaceVertex => {
|
||||
const denomY = dutch.middleHeight - input.wh
|
||||
const tPlane = Math.abs(denomY) > 1e-9 ? (point.y - input.wh) / denomY : 0
|
||||
const x = side * (input.w / 2 + (dutch.outerWaistHalfX - input.w / 2) * tPlane)
|
||||
return v(x, point.y, point.z)
|
||||
}
|
||||
const top2 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m2, r2, 0.5)), 1)
|
||||
const top3 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m3, r2, 0.5)), 1)
|
||||
const top1 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m1, r1, 0.5)), -1)
|
||||
const top4 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m4, r1, 0.5)), -1)
|
||||
return [
|
||||
[e2, e3, o3, top3, top2, o2],
|
||||
[e4, e1, o1, top1, top4, o4],
|
||||
]
|
||||
}
|
||||
|
||||
const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ)
|
||||
const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ)
|
||||
const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ)
|
||||
const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ)
|
||||
const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
const r1 = v(0, input.wh + input.rh, dutch.innerWaistHalfZ)
|
||||
const r2 = v(0, input.wh + input.rh, -dutch.innerWaistHalfZ)
|
||||
const projectDepthAxisPointToEndSlope = (
|
||||
point: RoofShapeFaceVertex,
|
||||
side: 1 | -1,
|
||||
): RoofShapeFaceVertex => {
|
||||
const denomY = dutch.middleHeight - input.wh
|
||||
const tPlane = Math.abs(denomY) > 1e-9 ? (point.y - input.wh) / denomY : 0
|
||||
const z = side * (input.d / 2 + (dutch.outerWaistHalfZ - input.d / 2) * tPlane)
|
||||
return v(point.x, point.y, z)
|
||||
}
|
||||
const top2 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m2, r1, 0.5)), 1)
|
||||
const top1 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m1, r1, 0.5)), 1)
|
||||
const top4 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m4, r2, 0.5)), -1)
|
||||
const top3 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m3, r2, 0.5)), -1)
|
||||
return [
|
||||
[e1, e2, o2, top2, top1, o1],
|
||||
[e3, e4, o4, top4, top3, o3],
|
||||
]
|
||||
}
|
||||
|
||||
export function getRoofModuleFaces(input: {
|
||||
type: RoofType
|
||||
w: number
|
||||
d: number
|
||||
wh: number
|
||||
rh: number
|
||||
baseY: number
|
||||
insets: RoofShapeInsets
|
||||
baseW: number
|
||||
baseD: number
|
||||
tanTheta: number
|
||||
shapeRatios: RoofShapeRatios
|
||||
excludeDutchEndSlopes?: boolean
|
||||
dutchTopRakeThickness?: number
|
||||
}): RoofShapeFaceVertex[][] {
|
||||
const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z })
|
||||
const { iF = 0, iB = 0, iL = 0, iR = 0 } = input.insets
|
||||
|
||||
const b1 = v(-input.w / 2 + iL, input.baseY, input.d / 2 - iF)
|
||||
const b2 = v(input.w / 2 - iR, input.baseY, input.d / 2 - iF)
|
||||
const b3 = v(input.w / 2 - iR, input.baseY, -input.d / 2 + iB)
|
||||
const b4 = v(-input.w / 2 + iL, input.baseY, -input.d / 2 + iB)
|
||||
const bottom = [b4, b3, b2, b1]
|
||||
|
||||
const e1 = v(-input.w / 2, input.wh, input.d / 2)
|
||||
const e2 = v(input.w / 2, input.wh, input.d / 2)
|
||||
const e3 = v(input.w / 2, input.wh, -input.d / 2)
|
||||
const e4 = v(-input.w / 2, input.wh, -input.d / 2)
|
||||
|
||||
const faces: RoofShapeFaceVertex[][] = []
|
||||
faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom)
|
||||
|
||||
const h = input.wh + Math.max(0.001, input.rh)
|
||||
|
||||
if (input.type === 'flat' || input.rh === 0) {
|
||||
faces.push([e1, e2, e3, e4])
|
||||
} else if (input.type === 'gable') {
|
||||
const r1 = v(-input.w / 2, h, 0)
|
||||
const r2 = v(input.w / 2, h, 0)
|
||||
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
|
||||
} else if (input.type === 'hip') {
|
||||
if (Math.abs(input.w - input.d) < 0.01) {
|
||||
const r = v(0, h, 0)
|
||||
faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r])
|
||||
} else if (input.w >= input.d) {
|
||||
const r1 = v(-input.w / 2 + input.d / 2, h, 0)
|
||||
const r2 = v(input.w / 2 - input.d / 2, h, 0)
|
||||
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
|
||||
} else {
|
||||
const r1 = v(0, h, input.d / 2 - input.w / 2)
|
||||
const r2 = v(0, h, -input.d / 2 + input.w / 2)
|
||||
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])
|
||||
}
|
||||
} else if (input.type === 'shed') {
|
||||
const t1 = v(-input.w / 2, h, -input.d / 2)
|
||||
const t2 = v(input.w / 2, h, -input.d / 2)
|
||||
faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1])
|
||||
} else if (input.type === 'gambrel') {
|
||||
const mz = (input.baseD / 2) * input.shapeRatios.gambrelLowerWidthRatio
|
||||
const dist = input.d / 2 - mz
|
||||
const mh = input.wh + dist * (input.tanTheta || 0)
|
||||
|
||||
const m1 = v(-input.w / 2, mh, mz)
|
||||
const m2 = v(input.w / 2, mh, mz)
|
||||
const m3 = v(input.w / 2, mh, -mz)
|
||||
const m4 = v(-input.w / 2, mh, -mz)
|
||||
const r1 = v(-input.w / 2, h, 0)
|
||||
const r2 = v(input.w / 2, h, 0)
|
||||
faces.push(
|
||||
[e4, e1, m1, r1, m4],
|
||||
[e2, e3, m3, r2, m2],
|
||||
[e1, e2, m2, m1],
|
||||
[m1, m2, r2, r1],
|
||||
[e3, e4, m4, m3],
|
||||
[m3, m4, r1, r2],
|
||||
)
|
||||
} else if (input.type === 'mansard') {
|
||||
const i = Math.min(input.baseW, input.baseD) * input.shapeRatios.mansardSteepWidthRatio
|
||||
const mh = input.wh + i * (input.tanTheta || 0)
|
||||
|
||||
const m1 = v(-input.w / 2 + i, mh, input.d / 2 - i)
|
||||
const m2 = v(input.w / 2 - i, mh, input.d / 2 - i)
|
||||
const m3 = v(input.w / 2 - i, mh, -input.d / 2 + i)
|
||||
const m4 = v(-input.w / 2 + i, mh, -input.d / 2 + i)
|
||||
const topW = input.w - i * 2
|
||||
const topD = input.d - i * 2
|
||||
|
||||
faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4])
|
||||
|
||||
if (Math.abs(topW - topD) < 0.01) {
|
||||
const r = v(0, h, 0)
|
||||
faces.push([m4, m1, r], [m1, m2, r], [m2, m3, r], [m3, m4, r])
|
||||
} else if (topW >= topD) {
|
||||
const r1 = v(-topW / 2 + topD / 2, h, 0)
|
||||
const r2 = v(topW / 2 - topD / 2, h, 0)
|
||||
faces.push([m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2])
|
||||
} else {
|
||||
const r1 = v(0, h, topD / 2 - topW / 2)
|
||||
const r2 = v(0, h, -topD / 2 + topW / 2)
|
||||
faces.push([m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2])
|
||||
}
|
||||
} else if (input.type === 'dutch') {
|
||||
const dutch = getDutchRoofShapeMetrics({
|
||||
w: input.w,
|
||||
d: input.d,
|
||||
wh: input.wh,
|
||||
rh: input.rh,
|
||||
dutchI: input.insets.dutchI,
|
||||
baseW: input.baseW,
|
||||
baseD: input.baseD,
|
||||
shapeRatios: input.shapeRatios,
|
||||
})
|
||||
if (!dutch) return faces
|
||||
|
||||
const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
|
||||
if (dutch.axis === 'width') {
|
||||
const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ)
|
||||
const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ)
|
||||
const r1 = v(-dutch.innerWaistHalfX, h, 0)
|
||||
const r2 = v(dutch.innerWaistHalfX, h, 0)
|
||||
const endSlopes = input.excludeDutchEndSlopes
|
||||
? []
|
||||
: getDutchEndSlopeFaces({
|
||||
w: input.w,
|
||||
d: input.d,
|
||||
wh: input.wh,
|
||||
rh: input.rh,
|
||||
insets: input.insets,
|
||||
baseW: input.baseW,
|
||||
baseD: input.baseD,
|
||||
shapeRatios: input.shapeRatios,
|
||||
dutchTopRakeThickness: input.dutchTopRakeThickness,
|
||||
})
|
||||
faces.push([e1, e2, o2, m2, m1, o1], [e3, e4, o4, m4, m3, o3])
|
||||
if (endSlopes.length === 2) {
|
||||
faces.push(...endSlopes)
|
||||
} else if (!input.excludeDutchEndSlopes) {
|
||||
faces.push([e2, e3, o3, o2], [e4, e1, o1, o4])
|
||||
}
|
||||
faces.push([m1, m2, r2, r1], [m3, m4, r1, r2])
|
||||
faces.push([m4, m1, r1], [m2, m3, r2])
|
||||
} else {
|
||||
const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ)
|
||||
const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ)
|
||||
const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ)
|
||||
const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ)
|
||||
const r1 = v(0, h, dutch.innerWaistHalfZ)
|
||||
const r2 = v(0, h, -dutch.innerWaistHalfZ)
|
||||
const endSlopes = input.excludeDutchEndSlopes
|
||||
? []
|
||||
: getDutchEndSlopeFaces({
|
||||
w: input.w,
|
||||
d: input.d,
|
||||
wh: input.wh,
|
||||
rh: input.rh,
|
||||
insets: input.insets,
|
||||
baseW: input.baseW,
|
||||
baseD: input.baseD,
|
||||
shapeRatios: input.shapeRatios,
|
||||
dutchTopRakeThickness: input.dutchTopRakeThickness,
|
||||
})
|
||||
faces.push([e2, e3, o3, m3, m2, o2], [e4, e1, o1, m1, m4, o4])
|
||||
if (endSlopes.length === 2) {
|
||||
faces.push(...endSlopes)
|
||||
} else if (!input.excludeDutchEndSlopes) {
|
||||
faces.push([e1, e2, o2, o1], [e3, e4, o4, o3])
|
||||
}
|
||||
faces.push([m2, m3, r2, r1], [m4, m1, r1, r2])
|
||||
faces.push([m1, m2, r1], [m3, m4, r2])
|
||||
}
|
||||
}
|
||||
|
||||
return faces
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getRoofSegmentSurfaceY, ROOF_SHAPE_DEFAULTS, RoofSegmentNode } from './roof-segment'
|
||||
|
||||
describe('getRoofSegmentSurfaceY', () => {
|
||||
test('keeps the Dutch width-axis rake on the upper gable slope', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'dutch',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
wallHeight: 3,
|
||||
pitch: 40,
|
||||
})
|
||||
const activeRh = getRoofSegmentSurfaceY(segment, 0, 0) - segment.wallHeight
|
||||
const upperRise = activeRh * (1 - segment.dutchHipHeightRatio)
|
||||
const waistHalfZ =
|
||||
segment.depth / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio
|
||||
const availableRake = Math.max(
|
||||
0,
|
||||
segment.width / 2 -
|
||||
(segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) *
|
||||
segment.dutchWaistLengthRatio,
|
||||
)
|
||||
const rakeReach = Math.min(segment.dutchGabletRake, availableRake * 0.98)
|
||||
const localX =
|
||||
(segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) *
|
||||
segment.dutchWaistLengthRatio +
|
||||
rakeReach * 0.5
|
||||
const localZ = waistHalfZ * 0.5
|
||||
|
||||
const expected = segment.wallHeight + activeRh - localZ * (upperRise / waistHalfZ)
|
||||
|
||||
expect(localX).toBeGreaterThan(
|
||||
(segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) *
|
||||
segment.dutchWaistLengthRatio,
|
||||
)
|
||||
expect(getRoofSegmentSurfaceY(segment, localX, localZ)).toBeCloseTo(expected, 6)
|
||||
})
|
||||
|
||||
test('keeps the Dutch depth-axis rake on the upper gable slope', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
roofType: 'dutch',
|
||||
width: 6,
|
||||
depth: 8,
|
||||
wallHeight: 3,
|
||||
pitch: 40,
|
||||
})
|
||||
const activeRh = getRoofSegmentSurfaceY(segment, 0, 0) - segment.wallHeight
|
||||
const upperRise = activeRh * (1 - segment.dutchHipHeightRatio)
|
||||
const waistHalfX =
|
||||
segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio
|
||||
const innerWaistHalfZ =
|
||||
(segment.depth / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) *
|
||||
segment.dutchWaistLengthRatio
|
||||
const rakeReach = Math.min(
|
||||
segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake,
|
||||
Math.max(0, segment.depth / 2 - innerWaistHalfZ) * 0.98,
|
||||
)
|
||||
const localX = waistHalfX * 0.5
|
||||
const localZ = innerWaistHalfZ + rakeReach * 0.5
|
||||
|
||||
const expected = segment.wallHeight + activeRh - localX * (upperRise / waistHalfX)
|
||||
|
||||
expect(localZ).toBeGreaterThan(innerWaistHalfZ)
|
||||
expect(getRoofSegmentSurfaceY(segment, localX, localZ)).toBeCloseTo(expected, 6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
getRoofSegmentVisibleTopBounds,
|
||||
normalizeRoofSegmentTrim,
|
||||
RoofSegmentNode,
|
||||
} from './roof-segment'
|
||||
|
||||
describe('roof segment trim', () => {
|
||||
test('defaults legacy segments to no trim', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test',
|
||||
type: 'roof-segment',
|
||||
})
|
||||
|
||||
expect(segment.trim).toEqual({
|
||||
left: 0,
|
||||
right: 0,
|
||||
front: 0,
|
||||
back: 0,
|
||||
frontLeft: 0,
|
||||
frontRight: 0,
|
||||
backLeft: 0,
|
||||
backRight: 0,
|
||||
frontLeftX: 0,
|
||||
frontLeftZ: 0,
|
||||
frontRightX: 0,
|
||||
frontRightZ: 0,
|
||||
backLeftX: 0,
|
||||
backLeftZ: 0,
|
||||
backRightX: 0,
|
||||
backRightZ: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizes impossible side totals without inverting the footprint', () => {
|
||||
const trim = normalizeRoofSegmentTrim({
|
||||
width: 4,
|
||||
depth: 3,
|
||||
trim: { left: 3, right: 3, front: 2, back: 2 },
|
||||
})
|
||||
|
||||
expect(trim.left + trim.right).toBeCloseTo(3.9)
|
||||
expect(trim.front + trim.back).toBeCloseTo(2.9)
|
||||
})
|
||||
|
||||
test('visible top bounds respect asymmetric trims', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test',
|
||||
type: 'roof-segment',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
overhang: 0,
|
||||
wallThickness: 0,
|
||||
shingleThickness: 0,
|
||||
trim: { left: 1, right: 2, front: 0, back: 0 },
|
||||
})
|
||||
|
||||
const bounds = getRoofSegmentVisibleTopBounds(segment)
|
||||
|
||||
expect(bounds.minX).toBeCloseTo(-3)
|
||||
expect(bounds.maxX).toBeCloseTo(2)
|
||||
expect(bounds.width).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
test('visible top bounds stay finite for legacy partial segments', () => {
|
||||
const bounds = getRoofSegmentVisibleTopBounds({
|
||||
id: 'rseg_legacy',
|
||||
type: 'roof-segment',
|
||||
roofType: 'gable',
|
||||
trim: { left: 1 },
|
||||
} as unknown as Parameters<typeof getRoofSegmentVisibleTopBounds>[0])
|
||||
|
||||
expect(Number.isFinite(bounds.minX)).toBe(true)
|
||||
expect(Number.isFinite(bounds.maxX)).toBe(true)
|
||||
expect(Number.isFinite(bounds.minZ)).toBe(true)
|
||||
expect(Number.isFinite(bounds.maxZ)).toBe(true)
|
||||
expect(Number.isFinite(bounds.width)).toBe(true)
|
||||
expect(Number.isFinite(bounds.depth)).toBe(true)
|
||||
})
|
||||
|
||||
test('diagonal trims can shorten the ridge span', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test',
|
||||
type: 'roof-segment',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
overhang: 0,
|
||||
wallThickness: 0,
|
||||
shingleThickness: 0,
|
||||
trim: { left: 0, right: 0, front: 0, back: 0, frontLeft: 4, backRight: 4 },
|
||||
})
|
||||
|
||||
const bounds = getRoofSegmentVisibleTopBounds(segment)
|
||||
|
||||
expect(bounds.minX).toBeCloseTo(-3)
|
||||
expect(bounds.maxX).toBeCloseTo(3)
|
||||
expect(bounds.width).toBeCloseTo(6)
|
||||
})
|
||||
|
||||
test('diagonal trims support independent width and depth endpoints', () => {
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test',
|
||||
type: 'roof-segment',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
overhang: 0,
|
||||
wallThickness: 0,
|
||||
shingleThickness: 0,
|
||||
trim: {
|
||||
frontLeftX: 5,
|
||||
frontLeftZ: 2,
|
||||
backRightX: 2,
|
||||
backRightZ: 5,
|
||||
},
|
||||
})
|
||||
|
||||
const trim = normalizeRoofSegmentTrim(segment)
|
||||
const bounds = getRoofSegmentVisibleTopBounds(segment)
|
||||
|
||||
expect(trim.frontLeft).toBeCloseTo(2)
|
||||
expect(trim.frontLeftX).toBeCloseTo(5)
|
||||
expect(trim.frontLeftZ).toBeCloseTo(2)
|
||||
expect(bounds.maxZ).toBeCloseTo(2.6)
|
||||
expect(bounds.maxX).toBeCloseTo(3.2)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
||||
import { RoofSegmentNode } from './roof-segment'
|
||||
import {
|
||||
getRoofSegmentWallFace,
|
||||
getRoofSegmentWallFaces,
|
||||
getRoofWallFaceFrame,
|
||||
roofFacePointToSegment,
|
||||
segmentPointToRoofWallFace,
|
||||
@@ -75,4 +76,39 @@ describe('roof wall face frames', () => {
|
||||
expect(getRoofWallFaceFrame(seg, faceId).yaw).toBe(getRoofSegmentWallFace(seg, faceId).yaw)
|
||||
}
|
||||
})
|
||||
|
||||
test('dutch width-axis roofs expose hostable gable-end wall profiles on the short ends', () => {
|
||||
const faces = getRoofSegmentWallFaces(
|
||||
segment({
|
||||
roofType: 'dutch',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
dutchHipWidthRatio: 0.25,
|
||||
dutchWaistLengthRatio: 1,
|
||||
}),
|
||||
)
|
||||
|
||||
const left = faces.find((face) => face.id === 'left')
|
||||
const right = faces.find((face) => face.id === 'right')
|
||||
|
||||
expect(left?.profile.length).toBeGreaterThan(5)
|
||||
expect(right?.profile.length).toBeGreaterThan(5)
|
||||
expect(left?.profile[3]?.[1]).toBeCloseTo(left?.profile[2]?.[1] ?? 0)
|
||||
})
|
||||
|
||||
test('dutch long-side faces stay rectangular while only the gable ends rise above the eave', () => {
|
||||
const faces = getRoofSegmentWallFaces(
|
||||
segment({
|
||||
roofType: 'dutch',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
}),
|
||||
)
|
||||
|
||||
const front = faces.find((face) => face.id === 'front')
|
||||
const back = faces.find((face) => face.id === 'back')
|
||||
|
||||
expect(front?.profile).toHaveLength(4)
|
||||
expect(back?.profile).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RoofSegmentNode } from './roof-segment'
|
||||
import { getSegmentSlopeFrame } from './roof-segment'
|
||||
import { getDutchRoofMetrics, getSegmentSlopeFrame } from './roof-segment'
|
||||
|
||||
/**
|
||||
* Wall-face math for roof segments — the vertical surfaces a wall-mounted
|
||||
@@ -53,6 +53,7 @@ type SegmentWallInputs = Pick<
|
||||
| 'mansardSteepHeightRatio'
|
||||
| 'dutchHipWidthRatio'
|
||||
| 'dutchHipHeightRatio'
|
||||
| 'dutchWaistLengthRatio'
|
||||
>
|
||||
>
|
||||
|
||||
@@ -173,8 +174,31 @@ function buildFaceProfile(
|
||||
[0, peakY],
|
||||
]
|
||||
}
|
||||
// hip / mansard / dutch slope on every side (dutch gablets are
|
||||
// recessed from the wall plane), so only the base rect is placeable.
|
||||
case 'dutch': {
|
||||
const metrics = getDutchRoofMetrics(node)
|
||||
const isDutchGableFace =
|
||||
(metrics.axis === 'x' && isEnd) ||
|
||||
(metrics.axis === 'z' && (id === 'front' || id === 'back'))
|
||||
if (!isDutchGableFace) return rectProfile(length, eaveY)
|
||||
|
||||
const shoulderInset =
|
||||
metrics.axis === 'x' ? metrics.shoulderInsetAlongDepth : metrics.shoulderInsetAlongWidth
|
||||
if (!(shoulderInset > 0.001)) return rectProfile(length, eaveY)
|
||||
|
||||
const shoulderLo = Math.max(0, shoulderInset)
|
||||
const shoulderHi = Math.min(length, length - shoulderInset)
|
||||
if (!(shoulderHi - shoulderLo > 0.02)) return rectProfile(length, eaveY)
|
||||
|
||||
return [
|
||||
[0, 0],
|
||||
[length, 0],
|
||||
[length, eaveY],
|
||||
[shoulderHi, eaveY],
|
||||
[length / 2, peakY],
|
||||
[shoulderLo, eaveY],
|
||||
[0, eaveY],
|
||||
]
|
||||
}
|
||||
default:
|
||||
return rectProfile(length, eaveY)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,50 @@ export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'man
|
||||
|
||||
export type RoofType = z.infer<typeof RoofType>
|
||||
|
||||
export const MIN_ROOF_SEGMENT_TRIM_SPAN = 0.1
|
||||
const DEFAULT_ROOF_SEGMENT_WIDTH = 8
|
||||
const DEFAULT_ROOF_SEGMENT_DEPTH = 6
|
||||
|
||||
export const RoofSegmentTrim = z
|
||||
.object({
|
||||
left: z.number().min(0).default(0),
|
||||
right: z.number().min(0).default(0),
|
||||
front: z.number().min(0).default(0),
|
||||
back: z.number().min(0).default(0),
|
||||
frontLeft: z.number().min(0).default(0),
|
||||
frontRight: z.number().min(0).default(0),
|
||||
backLeft: z.number().min(0).default(0),
|
||||
backRight: z.number().min(0).default(0),
|
||||
frontLeftX: z.number().min(0).default(0),
|
||||
frontLeftZ: z.number().min(0).default(0),
|
||||
frontRightX: z.number().min(0).default(0),
|
||||
frontRightZ: z.number().min(0).default(0),
|
||||
backLeftX: z.number().min(0).default(0),
|
||||
backLeftZ: z.number().min(0).default(0),
|
||||
backRightX: z.number().min(0).default(0),
|
||||
backRightZ: z.number().min(0).default(0),
|
||||
})
|
||||
.default({
|
||||
left: 0,
|
||||
right: 0,
|
||||
front: 0,
|
||||
back: 0,
|
||||
frontLeft: 0,
|
||||
frontRight: 0,
|
||||
backLeft: 0,
|
||||
backRight: 0,
|
||||
frontLeftX: 0,
|
||||
frontLeftZ: 0,
|
||||
frontRightX: 0,
|
||||
frontRightZ: 0,
|
||||
backLeftX: 0,
|
||||
backLeftZ: 0,
|
||||
backRightX: 0,
|
||||
backRightZ: 0,
|
||||
})
|
||||
|
||||
export type RoofSegmentTrim = z.infer<typeof RoofSegmentTrim>
|
||||
|
||||
// Default shape ratios. Tuning these used to require editing the geometry
|
||||
// code in two places; they are now schema fields with these defaults.
|
||||
export const ROOF_SHAPE_DEFAULTS = {
|
||||
@@ -23,6 +67,17 @@ export const ROOF_SHAPE_DEFAULTS = {
|
||||
dutchHipWidthRatio: 0.25,
|
||||
/** Dutch: hip face rises this fraction of the way to the peak. */
|
||||
dutchHipHeightRatio: 0.5,
|
||||
/** Dutch: gable waist span along the ridge axis, as a fraction of the max span. */
|
||||
dutchWaistLengthRatio: 0.98,
|
||||
/**
|
||||
* Dutch: how far the gablet's barge board extends outward past the gablet
|
||||
* end-wall, along the ridge axis, in metres. 0 disables the rake. The board
|
||||
* lies in the gablet's slope planes (coplanar with the main Dutch slopes)
|
||||
* and overhangs the lower hip skirt; the gablet end-wall itself stays put.
|
||||
*/
|
||||
dutchGabletRake: 0.48,
|
||||
/** Dutch: thickness of the top gable rake slab. */
|
||||
dutchTopRakeThickness: 0.21,
|
||||
} as const
|
||||
|
||||
export const RoofSegmentNode = BaseNode.extend({
|
||||
@@ -51,6 +106,10 @@ export const RoofSegmentNode = BaseNode.extend({
|
||||
// Footprint dimensions
|
||||
width: z.number().default(8),
|
||||
depth: z.number().default(6),
|
||||
// Segment-local distances trimmed from each footprint side. The trim
|
||||
// boundary is projected vertically through the roof volume, so the
|
||||
// resulting edge follows the actual sloped roof surfaces.
|
||||
trim: RoofSegmentTrim,
|
||||
// Wall height beneath the roof
|
||||
wallHeight: z.number().default(0.5),
|
||||
// Roof pitch in degrees — angle of the primary slope face.
|
||||
@@ -96,6 +155,17 @@ export const RoofSegmentNode = BaseNode.extend({
|
||||
.min(0.1)
|
||||
.max(0.9)
|
||||
.default(ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio),
|
||||
dutchWaistLengthRatio: z
|
||||
.number()
|
||||
.min(0.1)
|
||||
.max(1)
|
||||
.default(ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio),
|
||||
dutchGabletRake: z.number().min(0).max(3).default(ROOF_SHAPE_DEFAULTS.dutchGabletRake),
|
||||
dutchTopRakeThickness: z
|
||||
.number()
|
||||
.min(0.01)
|
||||
.max(0.5)
|
||||
.default(ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness),
|
||||
// Hosted accessories — chimney, dormer, skylight, box-vent,
|
||||
// ridge-vent, solar-panel, gutter. Each accessory's `parentId` points back
|
||||
// here; the segment renderer mounts them recursively via
|
||||
@@ -112,6 +182,7 @@ export const RoofSegmentNode = BaseNode.extend({
|
||||
Multiple segments can be combined to form complex roof shapes.
|
||||
- roofType: hip, gable, shed, gambrel, dutch, mansard, flat
|
||||
- width/depth: footprint dimensions
|
||||
- trim: segment-local side cut distances
|
||||
- wallHeight: height of walls below the roof
|
||||
- pitch: roof slope in degrees (angle of the primary slope face)
|
||||
- wallThickness/deckThickness: structural thicknesses
|
||||
@@ -120,11 +191,131 @@ export const RoofSegmentNode = BaseNode.extend({
|
||||
- gambrelLowerWidthRatio / gambrelLowerHeightRatio: kink position on gambrel roofs
|
||||
- mansardSteepWidthRatio / mansardSteepHeightRatio: waist position on mansard roofs
|
||||
- dutchHipWidthRatio / dutchHipHeightRatio: hip-to-gable split on dutch roofs
|
||||
- dutchWaistLengthRatio: gable waist span along the ridge axis
|
||||
- dutchGabletRake: gablet barge-board overhang past the gablet end-wall (m, 0 = none)
|
||||
- dutchTopRakeThickness: thickness of the top gable rake slab
|
||||
`,
|
||||
)
|
||||
|
||||
export type RoofSegmentNode = z.infer<typeof RoofSegmentNode>
|
||||
|
||||
function finiteNonNegative(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
|
||||
}
|
||||
|
||||
function finitePositive(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
function normalizeTrimAxis(start: unknown, end: unknown, span: number): readonly [number, number] {
|
||||
const maxTotal = Math.max(0, finiteNonNegative(span) - MIN_ROOF_SEGMENT_TRIM_SPAN)
|
||||
let a = Math.min(finiteNonNegative(start), maxTotal)
|
||||
let b = Math.min(finiteNonNegative(end), maxTotal)
|
||||
const total = a + b
|
||||
|
||||
if (total > maxTotal && total > 0) {
|
||||
const scale = maxTotal / total
|
||||
a *= scale
|
||||
b *= scale
|
||||
}
|
||||
|
||||
return [a, b] as const
|
||||
}
|
||||
|
||||
export function normalizeRoofSegmentTrim(
|
||||
node: Pick<RoofSegmentNode, 'width' | 'depth'> & { trim?: Partial<RoofSegmentTrim> },
|
||||
): RoofSegmentTrim {
|
||||
const trim = node.trim ?? {}
|
||||
const [left, right] = normalizeTrimAxis(trim.left, trim.right, node.width)
|
||||
const [back, front] = normalizeTrimAxis(trim.back, trim.front, node.depth)
|
||||
const maxWidthDiagonal = Math.max(0, finiteNonNegative(node.width) - left - right)
|
||||
const maxDepthDiagonal = Math.max(0, finiteNonNegative(node.depth) - front - back)
|
||||
const maxWidthPair = Math.max(0, maxWidthDiagonal - MIN_ROOF_SEGMENT_TRIM_SPAN)
|
||||
const maxDepthPair = Math.max(0, maxDepthDiagonal - MIN_ROOF_SEGMENT_TRIM_SPAN)
|
||||
let [frontLeftX, frontLeftZ] = normalizeCornerAxisTrim(
|
||||
trim.frontLeft,
|
||||
trim.frontLeftX,
|
||||
trim.frontLeftZ,
|
||||
maxWidthPair,
|
||||
maxDepthPair,
|
||||
)
|
||||
let [frontRightX, frontRightZ] = normalizeCornerAxisTrim(
|
||||
trim.frontRight,
|
||||
trim.frontRightX,
|
||||
trim.frontRightZ,
|
||||
maxWidthPair,
|
||||
maxDepthPair,
|
||||
)
|
||||
let [backLeftX, backLeftZ] = normalizeCornerAxisTrim(
|
||||
trim.backLeft,
|
||||
trim.backLeftX,
|
||||
trim.backLeftZ,
|
||||
maxWidthPair,
|
||||
maxDepthPair,
|
||||
)
|
||||
let [backRightX, backRightZ] = normalizeCornerAxisTrim(
|
||||
trim.backRight,
|
||||
trim.backRightX,
|
||||
trim.backRightZ,
|
||||
maxWidthPair,
|
||||
maxDepthPair,
|
||||
)
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
;[frontLeftX, frontRightX] = normalizeTrimPair(frontLeftX, frontRightX, maxWidthPair)
|
||||
;[backLeftX, backRightX] = normalizeTrimPair(backLeftX, backRightX, maxWidthPair)
|
||||
;[frontLeftZ, backLeftZ] = normalizeTrimPair(frontLeftZ, backLeftZ, maxDepthPair)
|
||||
;[frontRightZ, backRightZ] = normalizeTrimPair(frontRightZ, backRightZ, maxDepthPair)
|
||||
}
|
||||
|
||||
const frontLeft = Math.min(frontLeftX, frontLeftZ)
|
||||
const frontRight = Math.min(frontRightX, frontRightZ)
|
||||
const backLeft = Math.min(backLeftX, backLeftZ)
|
||||
const backRight = Math.min(backRightX, backRightZ)
|
||||
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
front,
|
||||
back,
|
||||
frontLeft,
|
||||
frontRight,
|
||||
backLeft,
|
||||
backRight,
|
||||
frontLeftX,
|
||||
frontLeftZ,
|
||||
frontRightX,
|
||||
frontRightZ,
|
||||
backLeftX,
|
||||
backLeftZ,
|
||||
backRightX,
|
||||
backRightZ,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTrimPair(a: number, b: number, maxTotal: number): [number, number] {
|
||||
const total = a + b
|
||||
if (total <= maxTotal || total <= 0) return [a, b]
|
||||
const scale = maxTotal / total
|
||||
return [a * scale, b * scale]
|
||||
}
|
||||
|
||||
function normalizeCornerAxisTrim(
|
||||
scalar: unknown,
|
||||
axisX: unknown,
|
||||
axisZ: unknown,
|
||||
maxX: number,
|
||||
maxZ: number,
|
||||
): [number, number] {
|
||||
const x = finiteNonNegative(axisX)
|
||||
const z = finiteNonNegative(axisZ)
|
||||
const fallback = finiteNonNegative(scalar)
|
||||
if (x > 0 || z > 0) {
|
||||
return [Math.min(x, maxX), Math.min(z, maxZ)]
|
||||
}
|
||||
return [Math.min(fallback, maxX), Math.min(fallback, maxZ)]
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pitch ↔ roof-peak height
|
||||
//
|
||||
@@ -144,6 +335,7 @@ type ShapeRatios = {
|
||||
mansardSteepHeightRatio: number
|
||||
dutchHipWidthRatio: number
|
||||
dutchHipHeightRatio: number
|
||||
dutchWaistLengthRatio: number
|
||||
}
|
||||
|
||||
type PitchInputs = {
|
||||
@@ -152,9 +344,44 @@ type PitchInputs = {
|
||||
depth: number
|
||||
} & Partial<ShapeRatios>
|
||||
|
||||
export type DutchRoofMetrics = {
|
||||
axis: 'x' | 'z'
|
||||
inset: number
|
||||
waistHalfX: number
|
||||
waistHalfZ: number
|
||||
ridgeStart: readonly [number, number]
|
||||
ridgeEnd: readonly [number, number]
|
||||
shoulderInsetAlongDepth: number
|
||||
shoulderInsetAlongWidth: number
|
||||
}
|
||||
|
||||
function getDutchUpperShellBounds(
|
||||
node: Pick<RoofSegmentNode, 'width' | 'depth'> &
|
||||
Partial<
|
||||
Pick<RoofSegmentNode, 'dutchHipWidthRatio' | 'dutchWaistLengthRatio' | 'dutchGabletRake'>
|
||||
>,
|
||||
) {
|
||||
const metrics = getDutchRoofMetrics(node)
|
||||
const width = finitePositive(node.width, DEFAULT_ROOF_SEGMENT_WIDTH)
|
||||
const depth = finitePositive(node.depth, DEFAULT_ROOF_SEGMENT_DEPTH)
|
||||
const rake = node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake
|
||||
const rakeReach =
|
||||
metrics.axis === 'x'
|
||||
? Math.min(Math.max(0, rake), Math.max(0, width / 2 - metrics.waistHalfX) * 0.98)
|
||||
: Math.min(Math.max(0, rake), Math.max(0, depth / 2 - metrics.waistHalfZ) * 0.98)
|
||||
|
||||
return {
|
||||
...metrics,
|
||||
upperHalfX: metrics.axis === 'x' ? metrics.waistHalfX + rakeReach : metrics.waistHalfX,
|
||||
upperHalfZ: metrics.axis === 'x' ? metrics.waistHalfZ : metrics.waistHalfZ + rakeReach,
|
||||
}
|
||||
}
|
||||
|
||||
function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios {
|
||||
return {
|
||||
...input,
|
||||
width: finitePositive(input.width, DEFAULT_ROOF_SEGMENT_WIDTH),
|
||||
depth: finitePositive(input.depth, DEFAULT_ROOF_SEGMENT_DEPTH),
|
||||
gambrelLowerWidthRatio:
|
||||
input.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
|
||||
gambrelLowerHeightRatio:
|
||||
@@ -165,6 +392,46 @@ function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios {
|
||||
input.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio,
|
||||
dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
|
||||
dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
|
||||
dutchWaistLengthRatio: input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
|
||||
}
|
||||
}
|
||||
|
||||
export function getDutchRoofMetrics(
|
||||
input: Pick<RoofSegmentNode, 'width' | 'depth'> &
|
||||
Partial<Pick<RoofSegmentNode, 'dutchHipWidthRatio' | 'dutchWaistLengthRatio'>>,
|
||||
): DutchRoofMetrics {
|
||||
const width = finitePositive(input.width, DEFAULT_ROOF_SEGMENT_WIDTH)
|
||||
const depth = finitePositive(input.depth, DEFAULT_ROOF_SEGMENT_DEPTH)
|
||||
const inset =
|
||||
Math.min(width, depth) * (input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio)
|
||||
const waistLengthRatio = input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio
|
||||
|
||||
if (width >= depth) {
|
||||
const waistHalfX = Math.max(0, (width / 2 - inset) * waistLengthRatio)
|
||||
const waistHalfZ = Math.max(0, depth / 2 - inset)
|
||||
return {
|
||||
axis: 'x',
|
||||
inset,
|
||||
waistHalfX,
|
||||
waistHalfZ,
|
||||
ridgeStart: [-waistHalfX, 0],
|
||||
ridgeEnd: [waistHalfX, 0],
|
||||
shoulderInsetAlongDepth: Math.max(0, depth / 2 - waistHalfZ),
|
||||
shoulderInsetAlongWidth: Math.max(0, width / 2 - waistHalfX),
|
||||
}
|
||||
}
|
||||
|
||||
const waistHalfX = Math.max(0, width / 2 - inset)
|
||||
const waistHalfZ = Math.max(0, (depth / 2 - inset) * waistLengthRatio)
|
||||
return {
|
||||
axis: 'z',
|
||||
inset,
|
||||
waistHalfX,
|
||||
waistHalfZ,
|
||||
ridgeStart: [0, waistHalfZ],
|
||||
ridgeEnd: [0, -waistHalfZ],
|
||||
shoulderInsetAlongDepth: Math.max(0, depth / 2 - waistHalfZ),
|
||||
shoulderInsetAlongWidth: Math.max(0, width / 2 - waistHalfX),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +519,86 @@ export function getActiveRoofHeight(node: Parameters<typeof getSegmentSlopeFrame
|
||||
return getSegmentSlopeFrame(node).activeRh
|
||||
}
|
||||
|
||||
export type RoofSegmentVisibleTopBounds = {
|
||||
minX: number
|
||||
maxX: number
|
||||
minZ: number
|
||||
maxZ: number
|
||||
width: number
|
||||
depth: number
|
||||
}
|
||||
|
||||
export function getRoofSegmentVisibleTopBounds(
|
||||
segment: RoofSegmentNode,
|
||||
): RoofSegmentVisibleTopBounds {
|
||||
const { activeRh, cosTheta, sinTheta } = getSegmentSlopeFrame(segment)
|
||||
const width = finitePositive(segment.width, DEFAULT_ROOF_SEGMENT_WIDTH)
|
||||
const depth = finitePositive(segment.depth, DEFAULT_ROOF_SEGMENT_DEPTH)
|
||||
const trim = normalizeRoofSegmentTrim({ ...segment, width, depth })
|
||||
const horizontalOverhang = finiteNonNegative(segment.overhang) * cosTheta
|
||||
const deckExt = finiteNonNegative(segment.wallThickness) / 2 + horizontalOverhang
|
||||
const shingleOverhang = finiteNonNegative(segment.shingleThickness) * sinTheta
|
||||
|
||||
let xExt = deckExt
|
||||
let frontExt = deckExt
|
||||
let backExt = deckExt
|
||||
|
||||
if (
|
||||
segment.roofType === 'hip' ||
|
||||
segment.roofType === 'mansard' ||
|
||||
segment.roofType === 'dutch'
|
||||
) {
|
||||
xExt += shingleOverhang
|
||||
frontExt += shingleOverhang
|
||||
backExt += shingleOverhang
|
||||
} else if (segment.roofType === 'gable' || segment.roofType === 'gambrel') {
|
||||
frontExt += shingleOverhang
|
||||
backExt += shingleOverhang
|
||||
} else if (segment.roofType === 'shed' && activeRh > 0) {
|
||||
frontExt += shingleOverhang
|
||||
}
|
||||
|
||||
let minX = trim.left > 0 ? -width / 2 + trim.left : -width / 2 - xExt
|
||||
let maxX = trim.right > 0 ? width / 2 - trim.right : width / 2 + xExt
|
||||
let minZ = trim.back > 0 ? -depth / 2 + trim.back : -depth / 2 - backExt
|
||||
let maxZ = trim.front > 0 ? depth / 2 - trim.front : depth / 2 + frontExt
|
||||
|
||||
if (trim.frontLeftX > 0 && trim.frontLeftZ > 0 && maxZ - trim.frontLeftZ < 0) {
|
||||
minX = Math.max(minX, minX + (trim.frontLeftX * (trim.frontLeftZ - maxZ)) / trim.frontLeftZ)
|
||||
}
|
||||
if (trim.backLeftX > 0 && trim.backLeftZ > 0 && minZ + trim.backLeftZ > 0) {
|
||||
minX = Math.max(minX, minX + (trim.backLeftX * (trim.backLeftZ + minZ)) / trim.backLeftZ)
|
||||
}
|
||||
if (trim.frontRightX > 0 && trim.frontRightZ > 0 && maxZ - trim.frontRightZ < 0) {
|
||||
maxX = Math.min(maxX, maxX - (trim.frontRightX * (trim.frontRightZ - maxZ)) / trim.frontRightZ)
|
||||
}
|
||||
if (trim.backRightX > 0 && trim.backRightZ > 0 && minZ + trim.backRightZ > 0) {
|
||||
maxX = Math.min(maxX, maxX - (trim.backRightX * (trim.backRightZ + minZ)) / trim.backRightZ)
|
||||
}
|
||||
|
||||
if (trim.frontLeftX > 0 && trim.frontLeftZ > 0 && minX + trim.frontLeftX > 0) {
|
||||
maxZ = Math.min(maxZ, maxZ - (trim.frontLeftZ * (trim.frontLeftX + minX)) / trim.frontLeftX)
|
||||
}
|
||||
if (trim.frontRightX > 0 && trim.frontRightZ > 0 && maxX - trim.frontRightX < 0) {
|
||||
maxZ = Math.min(maxZ, maxZ - (trim.frontRightZ * (trim.frontRightX - maxX)) / trim.frontRightX)
|
||||
}
|
||||
if (trim.backLeftX > 0 && trim.backLeftZ > 0 && minX + trim.backLeftX > 0) {
|
||||
minZ = Math.max(minZ, minZ + (trim.backLeftZ * (trim.backLeftX + minX)) / trim.backLeftX)
|
||||
}
|
||||
if (trim.backRightX > 0 && trim.backRightZ > 0 && maxX - trim.backRightX < 0) {
|
||||
minZ = Math.max(minZ, minZ + (trim.backRightZ * (trim.backRightX - maxX)) / trim.backRightX)
|
||||
}
|
||||
|
||||
return {
|
||||
minX,
|
||||
maxX,
|
||||
minZ,
|
||||
maxZ,
|
||||
width: Math.max(0.01, maxX - minX),
|
||||
depth: Math.max(0.01, maxZ - minZ),
|
||||
}
|
||||
}
|
||||
|
||||
/** Segment-local surface height used by roof accessory placement and hit disambiguation. */
|
||||
export function getRoofSegmentSurfaceY(
|
||||
node: Pick<RoofSegmentNode, 'roofType' | 'width' | 'depth' | 'wallHeight'> &
|
||||
@@ -259,20 +606,49 @@ export function getRoofSegmentSurfaceY(
|
||||
localX: number,
|
||||
localZ: number,
|
||||
): number {
|
||||
const activeRh = getActiveRoofHeight(node)
|
||||
const slopeFrame = getSegmentSlopeFrame(node)
|
||||
const activeRh = slopeFrame.activeRh
|
||||
const peakY = node.wallHeight + activeRh
|
||||
if (activeRh === 0) return node.wallHeight
|
||||
|
||||
if (
|
||||
node.roofType === 'gable' ||
|
||||
node.roofType === 'gambrel' ||
|
||||
node.roofType === 'mansard' ||
|
||||
node.roofType === 'dutch'
|
||||
) {
|
||||
if (node.roofType === 'gable' || node.roofType === 'gambrel' || node.roofType === 'mansard') {
|
||||
const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0
|
||||
return peakY - t * activeRh
|
||||
}
|
||||
|
||||
if (node.roofType === 'dutch') {
|
||||
const hipHeightRatio = node.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio
|
||||
const metrics = getDutchUpperShellBounds(node)
|
||||
const lowerRise = activeRh * hipHeightRatio
|
||||
if (metrics.axis === 'x') {
|
||||
const waistHalfZ = Math.max(0.0001, metrics.waistHalfZ)
|
||||
if (Math.abs(localX) <= metrics.upperHalfX && Math.abs(localZ) <= waistHalfZ) {
|
||||
const upperRise = activeRh * (1 - hipHeightRatio)
|
||||
const upperTan = upperRise / waistHalfZ
|
||||
return peakY - Math.abs(localZ) * upperTan
|
||||
}
|
||||
|
||||
const xProgressDenom = Math.max(0.0001, node.width / 2 - metrics.waistHalfX)
|
||||
const zProgressDenom = Math.max(0.0001, node.depth / 2 - waistHalfZ)
|
||||
const xProgress = Math.max(0, Math.abs(localX) - metrics.waistHalfX) / xProgressDenom
|
||||
const zProgress = Math.max(0, Math.abs(localZ) - waistHalfZ) / zProgressDenom
|
||||
return node.wallHeight + lowerRise * (1 - Math.min(1, Math.max(xProgress, zProgress)))
|
||||
}
|
||||
|
||||
const waistHalfX = Math.max(0.0001, metrics.waistHalfX)
|
||||
if (Math.abs(localX) <= waistHalfX && Math.abs(localZ) <= metrics.upperHalfZ) {
|
||||
const upperRise = activeRh * (1 - hipHeightRatio)
|
||||
const upperRun = waistHalfX
|
||||
const upperTan = upperRise / upperRun
|
||||
return peakY - Math.abs(localX) * upperTan
|
||||
}
|
||||
const xProgressDenom = Math.max(0.0001, node.width / 2 - waistHalfX)
|
||||
const zProgressDenom = Math.max(0.0001, node.depth / 2 - metrics.waistHalfZ)
|
||||
const xProgress = Math.max(0, Math.abs(localX) - waistHalfX) / xProgressDenom
|
||||
const zProgress = Math.max(0, Math.abs(localZ) - metrics.waistHalfZ) / zProgressDenom
|
||||
return node.wallHeight + lowerRise * (1 - Math.min(1, Math.max(xProgress, zProgress)))
|
||||
}
|
||||
|
||||
if (node.roofType === 'shed') {
|
||||
const t = (localZ + node.depth / 2) / (node.depth || 1)
|
||||
return peakY - t * activeRh
|
||||
|
||||
@@ -3,8 +3,12 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
AnyNode as AnyNodeSchema,
|
||||
createDefaultRidgeVentsForSegment,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getWallSurfaceMaterialSignature,
|
||||
isAutoRidgeVentEnabled,
|
||||
isDefaultRidgeVentNode,
|
||||
type RoofSegmentNode,
|
||||
type WallNode,
|
||||
} from '../../schema'
|
||||
import type { CollectionId } from '../../schema/collections'
|
||||
@@ -24,6 +28,21 @@ type WallMergePlan = {
|
||||
attachmentUpdates: WallAttachmentUpdate[]
|
||||
}
|
||||
|
||||
const DEFAULT_RIDGE_VENT_REFRESH_FIELDS = new Set<string>([
|
||||
'roofType',
|
||||
'width',
|
||||
'depth',
|
||||
'pitch',
|
||||
'overhang',
|
||||
'wallThickness',
|
||||
'shingleThickness',
|
||||
'gambrelLowerWidthRatio',
|
||||
'mansardSteepWidthRatio',
|
||||
'dutchHipWidthRatio',
|
||||
'dutchWaistLengthRatio',
|
||||
'dutchGabletRake',
|
||||
])
|
||||
|
||||
type ZodCheckLike = {
|
||||
_zod?: {
|
||||
def?: {
|
||||
@@ -496,6 +515,44 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode
|
||||
return { ...currentNode, ...(sanitized.value as Partial<AnyNode>) } as AnyNode
|
||||
}
|
||||
|
||||
function shouldRefreshDefaultRidgeVents(data: Partial<AnyNode>) {
|
||||
return Object.keys(data).some((key) => DEFAULT_RIDGE_VENT_REFRESH_FIELDS.has(key))
|
||||
}
|
||||
|
||||
function refreshDefaultRidgeVentsForSegment(
|
||||
nextNodes: Record<AnyNodeId, AnyNode>,
|
||||
segment: RoofSegmentNode,
|
||||
): AnyNodeId[] {
|
||||
const childIds = Array.isArray(segment.children) ? (segment.children as AnyNodeId[]) : []
|
||||
if (!isAutoRidgeVentEnabled(segment, nextNodes)) return []
|
||||
|
||||
const defaultIds = childIds.filter((childId) =>
|
||||
isDefaultRidgeVentNode(nextNodes[childId], segment.id),
|
||||
)
|
||||
const defaultIdSet = new Set(defaultIds)
|
||||
for (const id of defaultIds) {
|
||||
delete nextNodes[id]
|
||||
}
|
||||
|
||||
const nextVents = createDefaultRidgeVentsForSegment(segment)
|
||||
for (const vent of nextVents) {
|
||||
nextNodes[vent.id as AnyNodeId] = {
|
||||
...vent,
|
||||
parentId: segment.id,
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
nextNodes[segment.id as AnyNodeId] = {
|
||||
...segment,
|
||||
children: [
|
||||
...childIds.filter((childId) => !defaultIdSet.has(childId)),
|
||||
...nextVents.map((vent) => vent.id as AnyNodeId),
|
||||
],
|
||||
} as AnyNode
|
||||
|
||||
return nextVents.map((vent) => vent.id as AnyNodeId)
|
||||
}
|
||||
|
||||
// Track pending RAF for updateNodesAction to prevent multiple queued callbacks
|
||||
let pendingRafId: number | null = null
|
||||
let pendingUpdates: Set<AnyNodeId> = new Set()
|
||||
@@ -808,6 +865,11 @@ export const applyNodeChangesAction = (
|
||||
}
|
||||
|
||||
nextNodes[id] = updatedNode
|
||||
if (updatedNode.type === 'roof-segment' && shouldRefreshDefaultRidgeVents(data)) {
|
||||
for (const ventId of refreshDefaultRidgeVentsForSegment(nextNodes, updatedNode)) {
|
||||
nodesToMarkDirty.add(ventId)
|
||||
}
|
||||
}
|
||||
nodesToMarkDirty.add(id)
|
||||
}
|
||||
|
||||
@@ -905,6 +967,7 @@ export const updateNodesAction = (
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
const parentsToUpdate = new Set<AnyNodeId>()
|
||||
const extraNodesToUpdate = new Set<AnyNodeId>()
|
||||
|
||||
set((state) => {
|
||||
const nextNodes = { ...state.nodes }
|
||||
@@ -952,6 +1015,11 @@ export const updateNodesAction = (
|
||||
|
||||
// Apply the update
|
||||
nextNodes[id] = updatedNode
|
||||
if (updatedNode.type === 'roof-segment' && shouldRefreshDefaultRidgeVents(data)) {
|
||||
for (const ventId of refreshDefaultRidgeVentsForSegment(nextNodes, updatedNode)) {
|
||||
extraNodesToUpdate.add(ventId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: nextNodes }
|
||||
@@ -964,6 +1032,9 @@ export const updateNodesAction = (
|
||||
for (const pId of parentsToUpdate) {
|
||||
pendingUpdates.add(pId)
|
||||
}
|
||||
for (const id of extraNodesToUpdate) {
|
||||
pendingUpdates.add(id)
|
||||
}
|
||||
|
||||
if (pendingRafId !== null) {
|
||||
cancelAnimationFrame(pendingRafId)
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { createDefaultRidgeVentsForSegment, RidgeVentNode } from '../../schema/nodes/ridge-vent'
|
||||
import { RoofNode } from '../../schema/nodes/roof'
|
||||
import { RoofSegmentNode } from '../../schema/nodes/roof-segment'
|
||||
import type { AnyNode, AnyNodeId } from '../../schema/types'
|
||||
import useScene from '../use-scene'
|
||||
|
||||
type RafFn = (cb: (t: number) => void) => number
|
||||
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
|
||||
cb: (t: number) => void,
|
||||
) => {
|
||||
cb(0)
|
||||
return 0
|
||||
}) as RafFn
|
||||
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
|
||||
() => {}
|
||||
|
||||
describe('roof segment default ridge vents', () => {
|
||||
beforeEach(() => {
|
||||
useScene.setState({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
materials: {},
|
||||
readOnly: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('regenerates default ridge vents when the host ridge geometry changes', () => {
|
||||
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test' as never,
|
||||
parentId: roof.id,
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
})
|
||||
const defaults = createDefaultRidgeVentsForSegment(segment)
|
||||
const custom = RidgeVentNode.parse({
|
||||
id: 'rvent_custom' as never,
|
||||
parentId: segment.id,
|
||||
roofSegmentId: segment.id,
|
||||
name: 'Custom Ridge Vent',
|
||||
position: [0, 0.2, 0],
|
||||
length: 1.25,
|
||||
materialPreset: 'preset-custom',
|
||||
})
|
||||
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
|
||||
[segment.id]: {
|
||||
...segment,
|
||||
children: [...defaults.map((vent) => vent.id), custom.id],
|
||||
} as AnyNode,
|
||||
...Object.fromEntries(
|
||||
defaults.map((vent) => [
|
||||
vent.id,
|
||||
{ ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode,
|
||||
]),
|
||||
),
|
||||
[custom.id]: custom as AnyNode,
|
||||
} as Record<AnyNodeId, AnyNode>,
|
||||
[roof.id as AnyNodeId],
|
||||
)
|
||||
|
||||
const oldDefaultIds = defaults.map((vent) => vent.id)
|
||||
useScene.getState().updateNode(segment.id as AnyNodeId, { width: 12 } as Partial<AnyNode>)
|
||||
|
||||
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
const nextChildren = nextSegment?.children ?? []
|
||||
const nextDefaultIds = nextChildren.filter((id) => id !== custom.id)
|
||||
|
||||
expect(nextChildren).toContain(custom.id)
|
||||
expect(useScene.getState().nodes[custom.id as AnyNodeId]).toMatchObject({
|
||||
length: 1.25,
|
||||
materialPreset: 'preset-custom',
|
||||
})
|
||||
for (const oldId of oldDefaultIds) {
|
||||
expect(useScene.getState().nodes[oldId as AnyNodeId]).toBeUndefined()
|
||||
}
|
||||
expect(nextDefaultIds).toHaveLength(defaults.length)
|
||||
expect(
|
||||
nextDefaultIds.some((id) => {
|
||||
const node = useScene.getState().nodes[id as AnyNodeId]
|
||||
return node?.type === 'ridge-vent' && node.length > (defaults[0]?.length ?? 0)
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('does not regenerate default ridge vents when only trim changes', () => {
|
||||
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test' as never,
|
||||
parentId: roof.id,
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
})
|
||||
const defaults = createDefaultRidgeVentsForSegment(segment)
|
||||
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
|
||||
[segment.id]: {
|
||||
...segment,
|
||||
children: defaults.map((vent) => vent.id),
|
||||
} as AnyNode,
|
||||
...Object.fromEntries(
|
||||
defaults.map((vent) => [
|
||||
vent.id,
|
||||
{ ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode,
|
||||
]),
|
||||
),
|
||||
} as Record<AnyNodeId, AnyNode>,
|
||||
[roof.id as AnyNodeId],
|
||||
)
|
||||
|
||||
const originalDefaultId = defaults[0]?.id as AnyNodeId
|
||||
useScene.getState().updateNode(
|
||||
segment.id as AnyNodeId,
|
||||
{
|
||||
trim: { ...segment.trim, left: 2, frontLeftX: 2, frontLeftZ: 3 },
|
||||
} as Partial<AnyNode>,
|
||||
)
|
||||
|
||||
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
|
||||
expect(nextSegment?.children).toEqual(defaults.map((vent) => vent.id))
|
||||
expect(useScene.getState().nodes[originalDefaultId]).toMatchObject({
|
||||
id: originalDefaultId,
|
||||
length: defaults[0]?.length,
|
||||
position: defaults[0]?.position,
|
||||
})
|
||||
})
|
||||
|
||||
test('creates default ridge vents after a geometry change when auto ridge vent is enabled', () => {
|
||||
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test' as never,
|
||||
parentId: roof.id,
|
||||
roofType: 'flat',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
metadata: { autoRidgeVent: true },
|
||||
})
|
||||
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
|
||||
[segment.id]: segment as AnyNode,
|
||||
} as Record<AnyNodeId, AnyNode>,
|
||||
[roof.id as AnyNodeId],
|
||||
)
|
||||
|
||||
useScene.getState().updateNode(
|
||||
segment.id as AnyNodeId,
|
||||
{
|
||||
roofType: 'gable',
|
||||
} as Partial<AnyNode>,
|
||||
)
|
||||
|
||||
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
|
||||
expect(nextSegment?.children).toHaveLength(1)
|
||||
expect(useScene.getState().nodes[nextSegment?.children[0] as AnyNodeId]).toMatchObject({
|
||||
type: 'ridge-vent',
|
||||
roofSegmentId: segment.id,
|
||||
})
|
||||
})
|
||||
|
||||
test('regenerates default ridge vents when Dutch auto-vent fields change', () => {
|
||||
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test' as never,
|
||||
parentId: roof.id,
|
||||
roofType: 'dutch',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
metadata: { autoRidgeVent: true },
|
||||
})
|
||||
const defaults = createDefaultRidgeVentsForSegment(segment)
|
||||
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
|
||||
[segment.id]: {
|
||||
...segment,
|
||||
children: defaults.map((vent) => vent.id),
|
||||
} as AnyNode,
|
||||
...Object.fromEntries(
|
||||
defaults.map((vent) => [
|
||||
vent.id,
|
||||
{ ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode,
|
||||
]),
|
||||
),
|
||||
} as Record<AnyNodeId, AnyNode>,
|
||||
[roof.id as AnyNodeId],
|
||||
)
|
||||
|
||||
const originalDefaultIds = defaults.map((vent) => vent.id)
|
||||
useScene.getState().updateNode(
|
||||
segment.id as AnyNodeId,
|
||||
{
|
||||
pitch: 52,
|
||||
dutchWaistLengthRatio: 0.72,
|
||||
dutchGabletRake: 0.9,
|
||||
} as Partial<AnyNode>,
|
||||
)
|
||||
|
||||
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
const nextChildren = nextSegment?.children ?? []
|
||||
|
||||
expect(nextChildren).toHaveLength(defaults.length)
|
||||
expect(
|
||||
nextChildren.some((id) =>
|
||||
originalDefaultIds.includes(id as (typeof originalDefaultIds)[number]),
|
||||
),
|
||||
).toBe(false)
|
||||
for (const oldId of originalDefaultIds) {
|
||||
expect(useScene.getState().nodes[oldId as AnyNodeId]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('refresh replaces legacy default vents that still use preset-white', () => {
|
||||
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test' as never,
|
||||
parentId: roof.id,
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
metadata: { autoRidgeVent: true },
|
||||
})
|
||||
const legacyDefault = RidgeVentNode.parse({
|
||||
id: 'rvent_legacy' as never,
|
||||
parentId: segment.id,
|
||||
roofSegmentId: segment.id,
|
||||
name: 'Ridge Vent',
|
||||
style: 'shingled',
|
||||
materialPreset: 'preset-white',
|
||||
position: [0, 0, 0],
|
||||
length: 8,
|
||||
})
|
||||
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
|
||||
[segment.id]: {
|
||||
...segment,
|
||||
children: [legacyDefault.id],
|
||||
} as AnyNode,
|
||||
[legacyDefault.id]: legacyDefault as AnyNode,
|
||||
} as Record<AnyNodeId, AnyNode>,
|
||||
[roof.id as AnyNodeId],
|
||||
)
|
||||
|
||||
useScene.getState().updateNode(
|
||||
segment.id as AnyNodeId,
|
||||
{
|
||||
pitch: 52,
|
||||
} as Partial<AnyNode>,
|
||||
)
|
||||
|
||||
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
const nextChildren = nextSegment?.children ?? []
|
||||
|
||||
expect(nextChildren).toHaveLength(1)
|
||||
expect(nextChildren[0]).not.toBe(legacyDefault.id)
|
||||
expect(useScene.getState().nodes[legacyDefault.id as AnyNodeId]).toBeUndefined()
|
||||
})
|
||||
|
||||
test('refresh preserves user-created default-looking ridge vents without legacy preset metadata', () => {
|
||||
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test' as never,
|
||||
parentId: roof.id,
|
||||
roofType: 'gable',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
metadata: { autoRidgeVent: true },
|
||||
})
|
||||
const userVent = RidgeVentNode.parse({
|
||||
id: 'rvent_user' as never,
|
||||
parentId: segment.id,
|
||||
roofSegmentId: segment.id,
|
||||
name: 'Ridge Vent',
|
||||
style: 'shingled',
|
||||
position: [0, 0, 0],
|
||||
length: 8,
|
||||
})
|
||||
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
|
||||
[segment.id]: {
|
||||
...segment,
|
||||
children: [userVent.id],
|
||||
} as AnyNode,
|
||||
[userVent.id]: userVent as AnyNode,
|
||||
} as Record<AnyNodeId, AnyNode>,
|
||||
[roof.id as AnyNodeId],
|
||||
)
|
||||
|
||||
useScene.getState().updateNode(
|
||||
segment.id as AnyNodeId,
|
||||
{
|
||||
pitch: 52,
|
||||
} as Partial<AnyNode>,
|
||||
)
|
||||
|
||||
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
const nextChildren = nextSegment?.children ?? []
|
||||
|
||||
expect(nextChildren).toContain(userVent.id)
|
||||
expect(useScene.getState().nodes[userVent.id as AnyNodeId]).toMatchObject({
|
||||
id: userVent.id,
|
||||
roofSegmentId: segment.id,
|
||||
name: 'Ridge Vent',
|
||||
style: 'shingled',
|
||||
length: 8,
|
||||
})
|
||||
})
|
||||
|
||||
test('does not create default ridge vents after a geometry change when auto ridge vent is disabled', () => {
|
||||
const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] })
|
||||
const segment = RoofSegmentNode.parse({
|
||||
id: 'rseg_test' as never,
|
||||
parentId: roof.id,
|
||||
roofType: 'flat',
|
||||
width: 8,
|
||||
depth: 6,
|
||||
metadata: { autoRidgeVent: false },
|
||||
})
|
||||
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
[roof.id]: { ...roof, children: [segment.id] } as AnyNode,
|
||||
[segment.id]: segment as AnyNode,
|
||||
} as Record<AnyNodeId, AnyNode>,
|
||||
[roof.id as AnyNodeId],
|
||||
)
|
||||
|
||||
useScene.getState().updateNode(
|
||||
segment.id as AnyNodeId,
|
||||
{
|
||||
roofType: 'gable',
|
||||
} as Partial<AnyNode>,
|
||||
)
|
||||
|
||||
const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
|
||||
expect(nextSegment?.children ?? []).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -83,7 +83,7 @@ export function resolveElevatorServiceLevels(
|
||||
|
||||
export function getElevatorLevelHeight(levelId: string, nodes: Record<string, AnyNode>): number {
|
||||
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
|
||||
if (!level || level.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
|
||||
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
|
||||
|
||||
let maxTop = 0
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FenceNode } from '../../schema'
|
||||
import { getWallCurveFrameAt, getWallCurveLength, sampleWallCenterline } from '../wall/wall-curve'
|
||||
import type { Point2D } from '../wall/wall-mitering'
|
||||
import {
|
||||
getFenceSplineFrameAt,
|
||||
getFenceSplineLength,
|
||||
isSplineFence,
|
||||
sampleFenceSpline,
|
||||
} from './fence-spline'
|
||||
|
||||
/**
|
||||
* Unified fence centerline accessors. A fence is either:
|
||||
* - a spline fence (`path` of >= 2 control points) → smooth Catmull-Rom, or
|
||||
* - a straight / single-arc fence (`start`/`end` + optional `curveOffset`).
|
||||
*
|
||||
* These wrappers branch on `isSplineFence` and return the SAME shapes the wall
|
||||
* arc helpers return, so every consumer (3D geometry, 2D floor-plan, length,
|
||||
* handles) can sample the centerline without caring which kind it is. Wall arc
|
||||
* math in `wall-curve.ts` is untouched — walls never carry a `path`.
|
||||
*/
|
||||
|
||||
const DEFAULT_SAMPLE_SEGMENTS = 96
|
||||
|
||||
type CurveFrame = {
|
||||
point: Point2D
|
||||
tangent: Point2D
|
||||
normal: Point2D
|
||||
}
|
||||
|
||||
export function getFenceCenterlineFrameAt(fence: FenceNode, t: number): CurveFrame {
|
||||
if (isSplineFence(fence) && fence.path) {
|
||||
return getFenceSplineFrameAt(fence.path, t, fence.tangents)
|
||||
}
|
||||
return getWallCurveFrameAt(fence, t)
|
||||
}
|
||||
|
||||
export function sampleFenceCenterline(
|
||||
fence: FenceNode,
|
||||
segments = DEFAULT_SAMPLE_SEGMENTS,
|
||||
): Point2D[] {
|
||||
if (isSplineFence(fence) && fence.path) {
|
||||
// Spread the requested sample budget across the spans so a long path still
|
||||
// reads smoothly without exploding the point count.
|
||||
const spanCount = Math.max(1, fence.path.length - 1)
|
||||
const perSpan = Math.max(2, Math.ceil(segments / spanCount))
|
||||
return sampleFenceSpline(fence.path, fence.tangents, perSpan)
|
||||
}
|
||||
return sampleWallCenterline(fence, segments)
|
||||
}
|
||||
|
||||
export function getFenceCenterlineLength(
|
||||
fence: FenceNode,
|
||||
segments = DEFAULT_SAMPLE_SEGMENTS,
|
||||
): number {
|
||||
if (isSplineFence(fence) && fence.path) {
|
||||
const spanCount = Math.max(1, fence.path.length - 1)
|
||||
const perSpan = Math.max(2, Math.ceil(segments / spanCount))
|
||||
return getFenceSplineLength(fence.path, fence.tangents, perSpan)
|
||||
}
|
||||
return getWallCurveLength(fence, segments)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
getFenceControlHandle,
|
||||
getFenceSplineFrameAt,
|
||||
getFenceSplineLength,
|
||||
getTwoPointFenceCurveTangents,
|
||||
isSplineFence,
|
||||
sampleFenceSpline,
|
||||
} from './fence-spline'
|
||||
|
||||
describe('isSplineFence', () => {
|
||||
test('false without a path or with < 2 points', () => {
|
||||
expect(isSplineFence({ path: undefined })).toBe(false)
|
||||
expect(isSplineFence({ path: [[0, 0]] })).toBe(false)
|
||||
})
|
||||
|
||||
test('true with >= 2 points', () => {
|
||||
expect(
|
||||
isSplineFence({
|
||||
path: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sampleFenceSpline', () => {
|
||||
test('honors the control points as on-curve anchors', () => {
|
||||
const path: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[2, 2],
|
||||
[4, 0],
|
||||
]
|
||||
const sampled = sampleFenceSpline(path, undefined, 8)
|
||||
// First and last sample equal the path endpoints exactly.
|
||||
expect(sampled[0]).toEqual({ x: 0, y: 0 })
|
||||
expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 })
|
||||
// The interior control point is interpolated: some sample lands on (2, 2).
|
||||
const hitsMiddle = sampled.some((p) => Math.hypot(p.x - 2, p.y - 2) < 1e-6)
|
||||
expect(hitsMiddle).toBe(true)
|
||||
})
|
||||
|
||||
test('two-point path with no tangents is a straight segment', () => {
|
||||
expect(
|
||||
sampleFenceSpline(
|
||||
[
|
||||
[0, 0],
|
||||
[3, 0],
|
||||
],
|
||||
undefined,
|
||||
8,
|
||||
),
|
||||
).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('a stored tangent bends an otherwise straight two-point span', () => {
|
||||
const path: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
]
|
||||
// Pull the first point's handle up — the span should bow off the X axis.
|
||||
const sampled = sampleFenceSpline(path, [[0, 2], null], 12)
|
||||
expect(sampled[0]).toEqual({ x: 0, y: 0 })
|
||||
expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 })
|
||||
const maxY = Math.max(...sampled.map((p) => Math.abs(p.y)))
|
||||
expect(maxY).toBeGreaterThan(0.1)
|
||||
})
|
||||
|
||||
test('generated two-point curve tangents create a gentle arc', () => {
|
||||
const path: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
]
|
||||
const sampled = sampleFenceSpline(path, getTwoPointFenceCurveTangents(path), 16)
|
||||
expect(sampled[0]).toEqual({ x: 0, y: 0 })
|
||||
expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 })
|
||||
const maxY = Math.max(...sampled.map((p) => p.y))
|
||||
expect(maxY).toBeGreaterThan(0.4)
|
||||
})
|
||||
|
||||
test('produces a smooth (no-cusp) curve on uneven spacing', () => {
|
||||
const path: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[1, 0.2],
|
||||
[5, 0.3],
|
||||
[6, 0],
|
||||
]
|
||||
const sampled = sampleFenceSpline(path, undefined, 16)
|
||||
let maxTurn = 0
|
||||
for (let i = 2; i < sampled.length; i += 1) {
|
||||
const a = sampled[i - 2]!
|
||||
const b = sampled[i - 1]!
|
||||
const c = sampled[i]!
|
||||
const t1 = Math.atan2(b.y - a.y, b.x - a.x)
|
||||
const t2 = Math.atan2(c.y - b.y, c.x - b.x)
|
||||
let d = Math.abs(t2 - t1)
|
||||
if (d > Math.PI) d = 2 * Math.PI - d
|
||||
maxTurn = Math.max(maxTurn, d)
|
||||
}
|
||||
expect(maxTurn).toBeLessThan(Math.PI / 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFenceControlHandle', () => {
|
||||
test('returns the stored tangent when present', () => {
|
||||
expect(
|
||||
getFenceControlHandle(
|
||||
[
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
],
|
||||
[[1, 2], null],
|
||||
0,
|
||||
),
|
||||
).toEqual({ x: 1, y: 2 })
|
||||
})
|
||||
|
||||
test('falls back to the automatic distance-aware tangent', () => {
|
||||
const path: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[3, 0],
|
||||
[6, 0],
|
||||
]
|
||||
expect(getFenceControlHandle(path, undefined, 1)).toEqual({ x: 1, y: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFenceSplineFrameAt', () => {
|
||||
const path: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[4, 0],
|
||||
]
|
||||
|
||||
test('t=0 / t=1 land on the endpoints', () => {
|
||||
expect(getFenceSplineFrameAt(path, 0).point).toEqual({ x: 0, y: 0 })
|
||||
expect(getFenceSplineFrameAt(path, 1).point).toEqual({ x: 4, y: 0 })
|
||||
})
|
||||
|
||||
test('returns a unit tangent and perpendicular normal', () => {
|
||||
const frame = getFenceSplineFrameAt(path, 0.5)
|
||||
expect(Math.hypot(frame.tangent.x, frame.tangent.y)).toBeCloseTo(1, 5)
|
||||
const dot = frame.tangent.x * frame.normal.x + frame.tangent.y * frame.normal.y
|
||||
expect(dot).toBeCloseTo(0, 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFenceSplineLength', () => {
|
||||
test('roughly matches the straight distance for a straight path', () => {
|
||||
expect(
|
||||
getFenceSplineLength(
|
||||
[
|
||||
[0, 0],
|
||||
[3, 4],
|
||||
],
|
||||
undefined,
|
||||
8,
|
||||
),
|
||||
).toBeCloseTo(5, 5)
|
||||
})
|
||||
|
||||
test('a curved path is longer than its endpoint chord', () => {
|
||||
const path: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[2, 2],
|
||||
[4, 0],
|
||||
]
|
||||
const chord = Math.hypot(4, 0)
|
||||
expect(getFenceSplineLength(path, undefined, 16)).toBeGreaterThan(chord)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { FenceNode } from '../../schema'
|
||||
import type { Point2D } from '../wall/wall-mitering'
|
||||
|
||||
/**
|
||||
* Pure 2D spline sampling for fences whose centerline is defined by a `path`
|
||||
* of control points (the "flying path" curved fence).
|
||||
*
|
||||
* Each control point carries an OUT-handle offset vector. When the user has
|
||||
* not adjusted it, the handle defaults to a distance-aware Catmull-Rom-style
|
||||
* tangent: direction comes from neighbouring points, while length is capped by
|
||||
* the shorter adjacent span. When the user drags a tangent handle (stored in
|
||||
* `tangents[i]`), that point's handle becomes the stored vector and the IN
|
||||
* handle is its mirror, so the curve stays smooth (C1) through the point but
|
||||
* bends to taste. Each span is then a cubic Bézier between consecutive points
|
||||
* using their handles.
|
||||
*
|
||||
* Lives in `@pascal-app/core` and imports NO Three.js — the same `CurveFrame`
|
||||
* shape that `wall-curve.ts` returns (point / tangent / normal) is produced
|
||||
* here so the spline branch is a drop-in for the arc branch in every consumer.
|
||||
*/
|
||||
|
||||
const EPSILON = 1e-6
|
||||
const DEFAULT_SEGMENTS_PER_SPAN = 32
|
||||
const TWO_POINT_CURVE_SAGITTA_RATIO = 0.18
|
||||
const TWO_POINT_CURVE_MIN_SAGITTA = 0.18
|
||||
const TWO_POINT_CURVE_MAX_SAGITTA = 1.2
|
||||
|
||||
type FenceSplineLike = Pick<FenceNode, 'path'>
|
||||
type TangentList = ReadonlyArray<readonly [number, number] | null> | undefined
|
||||
|
||||
export function isSplineFence(fence: FenceSplineLike): boolean {
|
||||
return Array.isArray(fence.path) && fence.path.length >= 2
|
||||
}
|
||||
|
||||
type CurveFrame = {
|
||||
point: Point2D
|
||||
tangent: Point2D
|
||||
normal: Point2D
|
||||
}
|
||||
|
||||
function toPoints(path: ReadonlyArray<readonly [number, number]>): Point2D[] {
|
||||
return path.map(([x, y]) => ({ x, y }))
|
||||
}
|
||||
|
||||
function distance(a: Point2D, b: Point2D): number {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y)
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value))
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
/**
|
||||
* OUT-handle offset vector for control point `index` — the stored tangent if
|
||||
* the user has adjusted it, otherwise the automatic distance-aware tangent
|
||||
* (endpoints duplicate the neighbour so the ends stay tangent to their single
|
||||
* span). The IN handle is the negation of this.
|
||||
*
|
||||
* Exported so the editing UI can draw the tangent line / handle dots at the
|
||||
* right place even before the user has dragged them.
|
||||
*/
|
||||
export function getFenceControlHandle(
|
||||
path: ReadonlyArray<readonly [number, number]>,
|
||||
tangents: TangentList,
|
||||
index: number,
|
||||
): Point2D {
|
||||
const stored = tangents?.[index]
|
||||
if (stored) {
|
||||
return { x: stored[0], y: stored[1] }
|
||||
}
|
||||
const prev = path[index - 1] ?? path[index]!
|
||||
const next = path[index + 1] ?? path[index]!
|
||||
const prevDistance = distance(
|
||||
{ x: path[index]![0], y: path[index]![1] },
|
||||
{
|
||||
x: prev[0],
|
||||
y: prev[1],
|
||||
},
|
||||
)
|
||||
const nextDistance = distance(
|
||||
{ x: path[index]![0], y: path[index]![1] },
|
||||
{
|
||||
x: next[0],
|
||||
y: next[1],
|
||||
},
|
||||
)
|
||||
const handleLength = Math.min(prevDistance || nextDistance, nextDistance || prevDistance) / 3
|
||||
const vx = next[0] - prev[0]
|
||||
const vy = next[1] - prev[1]
|
||||
const len = Math.hypot(vx, vy)
|
||||
if (len < EPSILON || handleLength < EPSILON) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
x: (vx / len) * handleLength,
|
||||
y: (vy / len) * handleLength,
|
||||
}
|
||||
}
|
||||
|
||||
export function getTwoPointFenceCurveTangents(
|
||||
path: ReadonlyArray<readonly [number, number]>,
|
||||
): Array<[number, number] | null> | undefined {
|
||||
if (path.length !== 2) return undefined
|
||||
const start = path[0]!
|
||||
const end = path[1]!
|
||||
const dx = end[0] - start[0]
|
||||
const dy = end[1] - start[1]
|
||||
const chordLength = Math.hypot(dx, dy)
|
||||
if (chordLength < EPSILON) return undefined
|
||||
|
||||
const tangentX = dx / 3
|
||||
const tangentY = dy / 3
|
||||
const normalX = -dy / chordLength
|
||||
const normalY = dx / chordLength
|
||||
const sagitta = clamp(
|
||||
chordLength * TWO_POINT_CURVE_SAGITTA_RATIO,
|
||||
TWO_POINT_CURVE_MIN_SAGITTA,
|
||||
TWO_POINT_CURVE_MAX_SAGITTA,
|
||||
)
|
||||
const bendX = normalX * sagitta * (4 / 3)
|
||||
const bendY = normalY * sagitta * (4 / 3)
|
||||
|
||||
return [
|
||||
[tangentX + bendX, tangentY + bendY],
|
||||
[tangentX - bendX, tangentY - bendY],
|
||||
]
|
||||
}
|
||||
|
||||
function cubicBezier(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, u: number): Point2D {
|
||||
const mu = 1 - u
|
||||
const a = mu * mu * mu
|
||||
const b = 3 * mu * mu * u
|
||||
const c = 3 * mu * u * u
|
||||
const d = u * u * u
|
||||
return {
|
||||
x: a * p0.x + b * p1.x + c * p2.x + d * p3.x,
|
||||
y: a * p0.y + b * p1.y + c * p2.y + d * p3.y,
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnyTangent(tangents: TangentList): boolean {
|
||||
return Array.isArray(tangents) && tangents.some((t) => t != null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the spline centerline into a polyline. Control points are honored as
|
||||
* on-curve anchors; `segmentsPerSpan` controls smoothness between them.
|
||||
* Returns `(segmentsPerSpan * spanCount) + 1` points, first == path[0],
|
||||
* last == path[-1].
|
||||
*/
|
||||
export function sampleFenceSpline(
|
||||
path: ReadonlyArray<readonly [number, number]>,
|
||||
tangents?: TangentList,
|
||||
segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN,
|
||||
): Point2D[] {
|
||||
const pts = toPoints(path)
|
||||
if (pts.length === 0) return []
|
||||
if (pts.length === 1) return [pts[0]!]
|
||||
// Two points with no adjusted tangents is a straight segment.
|
||||
if (pts.length === 2 && !hasAnyTangent(tangents)) return [pts[0]!, pts[1]!]
|
||||
|
||||
const steps = Math.max(1, Math.floor(segmentsPerSpan))
|
||||
const result: Point2D[] = [pts[0]!]
|
||||
|
||||
for (let i = 0; i < pts.length - 1; i += 1) {
|
||||
const p1 = pts[i]!
|
||||
const p2 = pts[i + 1]!
|
||||
const outHandle = getFenceControlHandle(path, tangents, i)
|
||||
const nextHandle = getFenceControlHandle(path, tangents, i + 1)
|
||||
// Bézier controls: leave p1 along its OUT handle, arrive at p2 along its
|
||||
// IN handle (= negated OUT handle).
|
||||
const c1: Point2D = { x: p1.x + outHandle.x, y: p1.y + outHandle.y }
|
||||
const c2: Point2D = { x: p2.x - nextHandle.x, y: p2.y - nextHandle.y }
|
||||
|
||||
for (let s = 1; s <= steps; s += 1) {
|
||||
result.push(cubicBezier(p1, c1, c2, p2, s / steps))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function frameFromPolyline(points: Point2D[], t: number): CurveFrame {
|
||||
if (points.length === 0) {
|
||||
return {
|
||||
point: { x: 0, y: 0 },
|
||||
tangent: { x: 1, y: 0 },
|
||||
normal: { x: 0, y: 1 },
|
||||
}
|
||||
}
|
||||
if (points.length === 1) {
|
||||
return {
|
||||
point: points[0]!,
|
||||
tangent: { x: 1, y: 0 },
|
||||
normal: { x: 0, y: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
const clamped = clamp01(t)
|
||||
const lastIndex = points.length - 1
|
||||
const scaled = clamped * lastIndex
|
||||
const lower = Math.min(lastIndex - 1, Math.floor(scaled))
|
||||
const upper = lower + 1
|
||||
const localU = scaled - lower
|
||||
|
||||
const a = points[lower]!
|
||||
const b = points[upper]!
|
||||
const point = {
|
||||
x: a.x + (b.x - a.x) * localU,
|
||||
y: a.y + (b.y - a.y) * localU,
|
||||
}
|
||||
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const len = Math.hypot(dx, dy)
|
||||
const tangent = len < EPSILON ? { x: 1, y: 0 } : { x: dx / len, y: dy / len }
|
||||
|
||||
return {
|
||||
point,
|
||||
tangent,
|
||||
normal: { x: -tangent.y, y: tangent.x },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame (point + tangent + normal) at parameter `t` in [0, 1] along the spline
|
||||
* centerline. Same return shape as `getWallCurveFrameAt` so it is a drop-in for
|
||||
* the arc branch. `t` is uniform over the sampled polyline (arc length is not
|
||||
* reparameterised — adequate for marching posts / rails and far cheaper).
|
||||
*/
|
||||
export function getFenceSplineFrameAt(
|
||||
path: ReadonlyArray<readonly [number, number]>,
|
||||
t: number,
|
||||
tangents?: TangentList,
|
||||
segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN,
|
||||
): CurveFrame {
|
||||
return frameFromPolyline(sampleFenceSpline(path, tangents, segmentsPerSpan), t)
|
||||
}
|
||||
|
||||
/** Total polyline length of the sampled spline centerline. */
|
||||
export function getFenceSplineLength(
|
||||
path: ReadonlyArray<readonly [number, number]>,
|
||||
tangents?: TangentList,
|
||||
segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN,
|
||||
): number {
|
||||
const points = sampleFenceSpline(path, tangents, segmentsPerSpan)
|
||||
let total = 0
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
total += distance(points[i - 1]!, points[i]!)
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -149,6 +149,19 @@ function straightStairAABB(
|
||||
|
||||
const ARC_SAMPLES = 48
|
||||
|
||||
function getSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
|
||||
if ((stair.topLandingMode ?? 'none') !== 'integrated') return 0
|
||||
|
||||
const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9)
|
||||
const width = Math.max(stair.width ?? 1, 0.4)
|
||||
const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8))
|
||||
|
||||
return (
|
||||
Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) *
|
||||
Math.sign(sweepAngle || 1)
|
||||
)
|
||||
}
|
||||
|
||||
/** Bounding box of a curved / spiral stair's annular sector (plus the
|
||||
* integrated spiral top landing when present). */
|
||||
function arcStairAABB(stair: StairNode): StairFootprintAABB | null {
|
||||
@@ -158,7 +171,8 @@ function arcStairAABB(stair: StairNode): StairFootprintAABB | null {
|
||||
const width = Math.max(stair.width ?? 1, 0.4)
|
||||
const outerRadius = innerRadius + width
|
||||
|
||||
let sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
||||
const rawSweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
||||
let sweep = rawSweep
|
||||
// A full revolution would make the arc degenerate; clamp just under 2π the
|
||||
// same way the floor-plan emitter does so the sampled box stays correct.
|
||||
if (Math.abs(sweep) >= Math.PI * 2) sweep = Math.sign(sweep || 1) * (Math.PI * 2 - 0.001)
|
||||
@@ -175,17 +189,17 @@ function arcStairAABB(stair: StairNode): StairFootprintAABB | null {
|
||||
extendByLocal(box, stair, cos * outerRadius, sin * outerRadius)
|
||||
}
|
||||
|
||||
// Integrated spiral top landing — a rectangle hung off the outer rim.
|
||||
// Integrated spiral top landing renders as an angular extension of the
|
||||
// annular stair body, not as a rectangular box outside the outer rim.
|
||||
if (isSpiral && stair.topLandingMode === 'integrated') {
|
||||
const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1)
|
||||
const halfWidth = width / 2
|
||||
for (const [cornerX, cornerZ] of [
|
||||
[outerRadius, -halfWidth],
|
||||
[outerRadius + depth, -halfWidth],
|
||||
[outerRadius + depth, halfWidth],
|
||||
[outerRadius, halfWidth],
|
||||
] as const) {
|
||||
extendByLocal(box, stair, cornerX, cornerZ)
|
||||
const landingSweep = getSpiralLandingSweep(stair, rawSweep)
|
||||
const landingSteps = Math.max(1, Math.ceil(Math.abs(landingSweep) / (Math.PI / 24)))
|
||||
for (let step = 0; step <= landingSteps; step += 1) {
|
||||
const angle = rawSweep / 2 + (landingSweep * step) / landingSteps
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
extendByLocal(box, stair, cos * innerRadius, sin * innerRadius)
|
||||
extendByLocal(box, stair, cos * outerRadius, sin * outerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -508,4 +508,48 @@ describe('syncAutoStairOpenings', () => {
|
||||
expect(landingUpdate?.data.holes).toEqual([manualOpening])
|
||||
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'manual' }])
|
||||
})
|
||||
|
||||
test('does not add a separate rectangular hole for an integrated spiral top landing', () => {
|
||||
const building = BuildingNode.parse({ name: 'Building' })
|
||||
const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id })
|
||||
const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id })
|
||||
const landingSlab = SlabNode.parse({
|
||||
name: 'Landing Slab',
|
||||
parentId: upper.id,
|
||||
polygon: [
|
||||
[-4, -4],
|
||||
[4, -4],
|
||||
[4, 4],
|
||||
[-4, 4],
|
||||
],
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_spiral_landing',
|
||||
name: 'Spiral Landing Stair',
|
||||
parentId: ground.id,
|
||||
position: [0, 0, 0],
|
||||
rotation: Math.PI / 2,
|
||||
stairType: 'spiral',
|
||||
fromLevelId: ground.id,
|
||||
toLevelId: upper.id,
|
||||
slabOpeningMode: 'destination',
|
||||
innerRadius: 0.35,
|
||||
width: 1.2,
|
||||
sweepAngle: Math.PI * 1.6,
|
||||
topLandingMode: 'integrated',
|
||||
topLandingDepth: 1.1,
|
||||
})
|
||||
const nodes = Object.fromEntries(
|
||||
[building, ground, upper, landingSlab, stair].map((node) => [node.id, node]),
|
||||
) as Record<string, AnyNode>
|
||||
|
||||
const updates = syncAutoStairOpenings(nodes)
|
||||
const landingUpdate = updates.find((update) => update.id === landingSlab.id)
|
||||
const holes = landingUpdate?.data.holes ?? []
|
||||
const rectangularHoles = holes.filter((hole) => hole.length === 4)
|
||||
|
||||
expect(holes).toHaveLength(1)
|
||||
expect(rectangularHoles).toHaveLength(0)
|
||||
expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -454,22 +454,6 @@ function getSpiralOpeningPolygon(stair: StairNode, offset: number = 0): Point2D[
|
||||
})
|
||||
}
|
||||
|
||||
function getSpiralLandingPolygon(stair: StairNode, offset: number = 0): Point2D[] {
|
||||
const width = Math.max(stair.width ?? 1, 0.4)
|
||||
const outerRadius = Math.max(0.05, (stair.innerRadius ?? 0.9) + width)
|
||||
const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1)
|
||||
const halfWidth = width / 2
|
||||
|
||||
const localPoints: Point2D[] = [
|
||||
[outerRadius - offset, -halfWidth - offset],
|
||||
[outerRadius + depth + offset, -halfWidth - offset],
|
||||
[outerRadius + depth + offset, halfWidth + offset],
|
||||
[outerRadius - offset, halfWidth + offset],
|
||||
]
|
||||
|
||||
return localPoints.map(([x, z]) => toWorldPlanPoint(stair, x, z))
|
||||
}
|
||||
|
||||
function getStraightOpeningPolygonsForSurface(
|
||||
stair: StairNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
@@ -575,11 +559,7 @@ function getStairOpeningPolygons(
|
||||
|
||||
if (stair.stairType === 'spiral') {
|
||||
const offset = Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0)
|
||||
const polygons = [getSpiralOpeningPolygon(stair, offset)]
|
||||
if (stair.topLandingMode === 'integrated') {
|
||||
polygons.push(getSpiralLandingPolygon(stair, offset))
|
||||
}
|
||||
return polygons
|
||||
return [getSpiralOpeningPolygon(stair, offset)]
|
||||
}
|
||||
|
||||
if (typeof targetElevation === 'number') {
|
||||
|
||||
@@ -155,7 +155,7 @@ export function cloneLevelSubtree(
|
||||
levelId: AnyNodeId,
|
||||
): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map<string, string> } {
|
||||
const levelNode = nodes[levelId]
|
||||
if (!levelNode || levelNode.type !== 'level') {
|
||||
if (levelNode?.type !== 'level') {
|
||||
throw new Error(`Node "${levelId}" is not a level`)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type FloorplanMoveTargetSession,
|
||||
nodeRegistry,
|
||||
pauseSceneHistory,
|
||||
resolveAlignment,
|
||||
resumeSceneHistory,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
@@ -22,6 +21,7 @@ import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
|
||||
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
|
||||
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
|
||||
import { useMovingNode } from '../../store/use-interaction-scope'
|
||||
@@ -455,11 +455,13 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
// point by the cursor delta and commit the translated `path` instead.
|
||||
// The reference origin is the path centre so the SVG `translate` delta
|
||||
// matches the geometry's actual location (which isn't at [0,0,0]).
|
||||
// Only 3D `[x, y, z]` polyline kinds (duct / pipe / lineset) are handled
|
||||
// here. A spline fence also carries a `path`, but it is 2D (`[x, y]`) and
|
||||
// moves through its own `floorplanMoveTarget`, so exclude shorter tuples.
|
||||
const rawPath = (movingNode as { path?: unknown }).path
|
||||
const originalPath =
|
||||
'path' in movingNode && Array.isArray((movingNode as { path?: unknown }).path)
|
||||
? (movingNode as { path: [number, number, number][] }).path.map(
|
||||
(p) => [...p] as [number, number, number],
|
||||
)
|
||||
Array.isArray(rawPath) && Array.isArray(rawPath[0]) && rawPath[0].length >= 3
|
||||
? (rawPath as [number, number, number][]).map((p) => [...p] as [number, number, number])
|
||||
: null
|
||||
const originalPosition: [number, number, number] = originalPath
|
||||
? (() => {
|
||||
@@ -574,7 +576,7 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
// store, which the 2D FloorplanAlignmentGuideLayer renders
|
||||
// inside the rotated scene <g>. The 3D pipeline uses a
|
||||
// separate store, so frames stay isolated per surface.
|
||||
const result = resolveAlignment({
|
||||
const result = resolveAlignmentForFloorplanView({
|
||||
moving: movingAnchors,
|
||||
candidates: candidateAnchors,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
|
||||
@@ -14,12 +14,14 @@ import { useFloorplanRender } from './floorplan-render-context'
|
||||
* endpoint (corner) → square midpoint → triangle
|
||||
* intersection → ✕ cross wall body (edge) → circle
|
||||
*
|
||||
* Indigo to match the 3D beacon and stay distinct from the red Figma alignment
|
||||
* guides. Sizes are pixel-budgeted via `unitsPerPixel` so the marker stays a
|
||||
* Indigo to match the 3D beacon, except the corner (endpoint) square which is
|
||||
* green to match the alignment guides. Sizes are pixel-budgeted via
|
||||
* `unitsPerPixel` so the marker stays a
|
||||
* constant size on screen at any zoom. Mounted inside the `data-floorplan-scene`
|
||||
* group so coordinates are world meters (XZ) 1:1, like the alignment guides.
|
||||
*/
|
||||
const COLOR = '#6366f1' // indigo-500 — matches the 3D beacon
|
||||
const ENDPOINT_COLOR = '#22c55e' // green-500 — corner (endpoint) snap accent
|
||||
|
||||
export const FloorplanSnapBeaconLayer = memo(function FloorplanSnapBeaconLayer() {
|
||||
const point = useWallSnapIndicator((s) => s.point)
|
||||
@@ -54,7 +56,7 @@ function SnapMarker({
|
||||
z: number
|
||||
}) {
|
||||
if (kind === 'endpoint') {
|
||||
return <rect fill={color} height={m * 2} width={m * 2} x={x - m} y={z - m} />
|
||||
return <rect fill={ENDPOINT_COLOR} height={m * 2} width={m * 2} x={x - m} y={z - m} />
|
||||
}
|
||||
if (kind === 'midpoint') {
|
||||
const t = m * 1.3
|
||||
|
||||
@@ -44,9 +44,11 @@ import { createEditorApi } from '../../../lib/editor-api'
|
||||
import {
|
||||
type ActiveInteractionScope,
|
||||
boundaryReshapeScope,
|
||||
controlPointReshapeScope,
|
||||
curveReshapeScope,
|
||||
endpointReshapeScope,
|
||||
holeEditScope,
|
||||
tangentReshapeScope,
|
||||
} from '../../../lib/interaction/scope'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
||||
@@ -151,6 +153,14 @@ function affordanceReshapeScope(
|
||||
if (affordance.includes('curve')) {
|
||||
return curveReshapeScope(nodeId)
|
||||
}
|
||||
if (affordance.includes('control-point')) {
|
||||
const index = (payload as { index?: number } | undefined)?.index ?? 0
|
||||
return controlPointReshapeScope(nodeId, index)
|
||||
}
|
||||
if (affordance.includes('tangent')) {
|
||||
const target = payload as { index?: number; side?: 'in' | 'out' } | undefined
|
||||
return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out')
|
||||
}
|
||||
if (affordance.includes('endpoint')) {
|
||||
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
|
||||
return endpointReshapeScope(nodeId, endpoint)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
@@ -9,6 +8,7 @@ import { memo, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, CircleGeometry, type Group, Vector3 } from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import { formatMeasurement } from './measurement-pill'
|
||||
|
||||
/**
|
||||
@@ -49,6 +49,14 @@ const guideMaterial = new MeshBasicNodeMaterial({
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
})
|
||||
const DOT_COLOR = 0x22_c5_5e // green-500 — matches the wall-snap marker
|
||||
const dotMaterial = new MeshBasicNodeMaterial({
|
||||
color: DOT_COLOR,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
})
|
||||
const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
|
||||
const DOT_GEOMETRY = new CircleGeometry(1, 24)
|
||||
|
||||
@@ -150,7 +158,7 @@ function Dot({ position }: { position: Vec3 }) {
|
||||
<mesh
|
||||
geometry={DOT_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
material={guideMaterial}
|
||||
material={dotMaterial}
|
||||
position={position}
|
||||
renderOrder={1001}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
|
||||
@@ -457,12 +457,7 @@ export const CustomCameraControls = () => {
|
||||
|
||||
return useEditor.subscribe((state) => {
|
||||
const pose = state.navigationSyncPose
|
||||
if (
|
||||
!pose ||
|
||||
pose.source !== '2d' ||
|
||||
pose.revision === lastApplied2dNavigationRevision.current
|
||||
)
|
||||
return
|
||||
if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return
|
||||
|
||||
const control = controls.current
|
||||
if (!control) return
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FenceNode,
|
||||
getFenceControlHandle,
|
||||
isSplineFence,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { BufferGeometry, type Object3D, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
|
||||
/**
|
||||
* Straight connecting line through each spline-fence control point joining its
|
||||
* two tangent handle ends (the classic pen-tool look). The handle *dots* are
|
||||
* registry tap-handles (see `fence/definition.ts`); this overlay only draws
|
||||
* the line between them, in both views' 3D scene.
|
||||
*
|
||||
* Must match the handle placement's arm scale so the line ends land exactly on
|
||||
* the dots. Portals into the fence's own Object3D so it inherits the same world
|
||||
* transform as the geometry (path coords are node-local plan meters), and reads
|
||||
* the live override so the line tracks an in-flight tangent / point drag.
|
||||
*/
|
||||
|
||||
// Keep in sync with TANGENT_HANDLE_ARM_SCALE in fence/definition.ts.
|
||||
const TANGENT_HANDLE_ARM_SCALE = 3
|
||||
const LINE_LIFT_Y = 0.02
|
||||
|
||||
export function FenceTangentLines3D() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
|
||||
const rawNode = useScene((s) => (selectedId ? s.nodes[selectedId as AnyNodeId] : null))
|
||||
const liveOverride = useLiveNodeOverrides((s) =>
|
||||
selectedId ? s.overrides.get(selectedId as AnyNodeId) : undefined,
|
||||
)
|
||||
const fence = useMemo<FenceNode | null>(() => {
|
||||
if (rawNode?.type !== 'fence') return null
|
||||
const merged = liveOverride ? ({ ...rawNode, ...liveOverride } as FenceNode) : rawNode
|
||||
return isSplineFence(merged) ? merged : null
|
||||
}, [rawNode, liveOverride])
|
||||
|
||||
const [object, setObject] = useState<{ id: AnyNodeId; object: Object3D } | null>(null)
|
||||
const selectedObject = selectedId && object?.id === selectedId ? object.object : null
|
||||
|
||||
useFrame(() => {
|
||||
if (!selectedId || selectedObject) return
|
||||
const next = sceneRegistry.nodes.get(selectedId)
|
||||
if (next) setObject({ id: selectedId as AnyNodeId, object: next })
|
||||
})
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
if (!fence?.path) return null
|
||||
const positions: Vector3[] = []
|
||||
for (let i = 0; i < fence.path.length; i += 1) {
|
||||
const point = fence.path[i]!
|
||||
const handle = getFenceControlHandle(fence.path, fence.tangents, i)
|
||||
const ax = handle.x * TANGENT_HANDLE_ARM_SCALE
|
||||
const az = handle.y * TANGENT_HANDLE_ARM_SCALE
|
||||
// One disjoint segment per point — `lineSegments` pairs vertices, so the
|
||||
// in/out ends connect through the point without joining across points.
|
||||
positions.push(new Vector3(point[0] - ax, LINE_LIFT_Y, point[1] - az))
|
||||
positions.push(new Vector3(point[0] + ax, LINE_LIFT_Y, point[1] + az))
|
||||
}
|
||||
return new BufferGeometry().setFromPoints(positions)
|
||||
}, [fence])
|
||||
|
||||
if (!(fence && selectedObject && geometry)) return null
|
||||
|
||||
return createPortal(
|
||||
<lineSegments frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={3}>
|
||||
<lineBasicNodeMaterial
|
||||
color="#8381ed"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.85}
|
||||
transparent
|
||||
/>
|
||||
</lineSegments>,
|
||||
selectedObject,
|
||||
)
|
||||
}
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
getWallCurveLength,
|
||||
getWallThickness,
|
||||
ItemNode,
|
||||
isCurvedWall,
|
||||
isRegistryMovable,
|
||||
isRegistrySelectable,
|
||||
isSplineFence,
|
||||
nodeRegistry,
|
||||
RoofSegmentNode,
|
||||
type SlabNode,
|
||||
@@ -691,7 +693,8 @@ export function FloatingActionMenu() {
|
||||
onFind={node && canFindNode ? handleFind : undefined}
|
||||
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
||||
onCurve={
|
||||
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
|
||||
(node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
|
||||
(node?.type === 'wall' && canCurveSelectedWall)
|
||||
? handleCurve
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function FloatingBuildingActionMenu() {
|
||||
// Read lazily at click time — no need to subscribe to nodes for a
|
||||
// one-shot action.
|
||||
const node = useScene.getState().nodes[buildingId]
|
||||
if (!node || node.type !== 'building') return
|
||||
if (node?.type !== 'building') return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node as BuildingNode)
|
||||
setSelection({ buildingId: null })
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
type ZoneNode as ZoneNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides, useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor'
|
||||
import { useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Command, Ruler } from 'lucide-react'
|
||||
import {
|
||||
@@ -91,6 +91,7 @@ import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
||||
import useEditor, {
|
||||
isAngleSnapActive,
|
||||
@@ -5105,13 +5106,13 @@ export function FloorplanPanel({
|
||||
const hasAmbientBuildingLevel = useScene((state) => {
|
||||
if (levelId || !ambientBuildingId) return false
|
||||
const building = state.nodes[ambientBuildingId]
|
||||
if (!building || building.type !== 'building') return false
|
||||
if (building?.type !== 'building') return false
|
||||
return building.children.some((cid) => state.nodes[cid]?.type === 'level')
|
||||
})
|
||||
const elevators = useScene(
|
||||
useShallow((state) => {
|
||||
const building = currentBuildingId ? state.nodes[currentBuildingId] : null
|
||||
if (!building || building.type !== 'building') {
|
||||
if (building?.type !== 'building') {
|
||||
return [] as ElevatorNode[]
|
||||
}
|
||||
|
||||
@@ -5889,6 +5890,7 @@ export function FloorplanPanel({
|
||||
const isOpeningMoveActive = movingOpeningType !== null
|
||||
const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
|
||||
const isFenceBuildActive = phase === 'structure' && mode === 'build' && tool === 'fence'
|
||||
const fenceContinuation = useEditor((state) => state.continuationByContext.fence)
|
||||
const isRoofBuildActive = phase === 'structure' && mode === 'build' && tool === 'roof'
|
||||
const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
|
||||
const isStairMoveActive = movingNode?.type === 'stair'
|
||||
@@ -5945,18 +5947,16 @@ export function FloorplanPanel({
|
||||
isRegistryToolBuildActive
|
||||
const floorplanOpeningLocalY = useMemo(() => {
|
||||
if (movingNode?.type === 'door' || movingNode?.type === 'window') {
|
||||
return shiftPressed ? movingNode.position[1] : snapToHalf(movingNode.position[1])
|
||||
return snapToHalf(movingNode.position[1])
|
||||
}
|
||||
|
||||
if (isWindowBuildActive) {
|
||||
// Floorplan is top-down, so new windows need an explicit wall-local height.
|
||||
return shiftPressed
|
||||
? FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y
|
||||
: snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y)
|
||||
return snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y)
|
||||
}
|
||||
|
||||
return 0
|
||||
}, [isWindowBuildActive, movingNode, shiftPressed])
|
||||
}, [isWindowBuildActive, movingNode])
|
||||
// Float the faithful door/window symbol at the cursor while it isn't over a
|
||||
// wall (the off-wall placement ghost), by publishing a transient opening on a
|
||||
// synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer`
|
||||
@@ -6488,7 +6488,7 @@ export function FloorplanPanel({
|
||||
// hidden; a no-op while closed (the sync early-returns).
|
||||
syncFloorplanViewportToNavigationPose(pose)
|
||||
}
|
||||
}, [isFloorplanOpen, syncFloorplanViewportToNavigationPose])
|
||||
}, [syncFloorplanViewportToNavigationPose])
|
||||
|
||||
useEffect(() => {
|
||||
return useEditor.subscribe((state) => {
|
||||
@@ -7154,7 +7154,7 @@ export function FloorplanPanel({
|
||||
setCursorPoint(planPoint)
|
||||
return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial<ElevatorNode>
|
||||
},
|
||||
[],
|
||||
[setCursorPoint],
|
||||
)
|
||||
|
||||
const handleElevatorResizePointerDown = useCallback(
|
||||
@@ -7227,7 +7227,13 @@ export function FloorplanPanel({
|
||||
setElevatorResizeDragState(null)
|
||||
setCursorPoint(null)
|
||||
},
|
||||
[elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize, updateNode],
|
||||
[
|
||||
elevatorResizeDragState,
|
||||
getPlanPointFromClientPoint,
|
||||
previewElevatorResize,
|
||||
updateNode,
|
||||
setCursorPoint,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -7504,15 +7510,15 @@ export function FloorplanPanel({
|
||||
setWallChainFirstVertex(null)
|
||||
setDraftEnd(null)
|
||||
useSegmentDraftChain.getState().clear('wall')
|
||||
}, [])
|
||||
}, [setDraftEnd])
|
||||
const clearFencePlacementDraft = useCallback(() => {
|
||||
setFenceDraftStart(null)
|
||||
setFenceDraftEnd(null)
|
||||
}, [])
|
||||
}, [setFenceDraftEnd])
|
||||
const clearRoofPlacementDraft = useCallback(() => {
|
||||
setRoofDraftStart(null)
|
||||
setRoofDraftEnd(null)
|
||||
}, [])
|
||||
}, [setRoofDraftEnd])
|
||||
const clearCeilingPlacementDraft = useCallback(() => {
|
||||
setCeilingDraftPoints([])
|
||||
}, [])
|
||||
@@ -7585,6 +7591,7 @@ export function FloorplanPanel({
|
||||
clearWallEndpointDrag,
|
||||
clearWallPlacementDraft,
|
||||
clearZonePlacementDraft,
|
||||
setCursorPoint,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -8020,14 +8027,13 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
// Wall endpoint move: grid snap only. Shift bypasses all snap.
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
// Wall endpoint move: snapping follows the active mode; there is no
|
||||
// held-key bypass.
|
||||
const snapResult = snapWallDraftPointDetailed({
|
||||
point: planPoint,
|
||||
walls,
|
||||
ignoreWallIds: [dragState.wallId],
|
||||
bypassSnap,
|
||||
magnetic: !bypassSnap && isMagneticSnapActive(),
|
||||
magnetic: isMagneticSnapActive(),
|
||||
})
|
||||
const snappedPoint = snapResult.point
|
||||
// Magnetic beacon at the endpoint when it locked onto existing geometry.
|
||||
@@ -8068,7 +8074,6 @@ export function FloorplanPanel({
|
||||
)
|
||||
|
||||
if (
|
||||
!bypassSnap &&
|
||||
!(
|
||||
previousDraft &&
|
||||
pointsEqual(previousDraft.start, nextDraft.start) &&
|
||||
@@ -8097,18 +8102,12 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
const chord = getWallChordFrame(wall)
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const snappedPoint: WallPlanPoint = bypassSnap
|
||||
? planPoint
|
||||
: [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
||||
const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
||||
const rawCurveOffset = -(
|
||||
(snappedPoint[0] - chord.midpoint.x) * chord.normal.x +
|
||||
(snappedPoint[1] - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
wall,
|
||||
bypassSnap ? rawCurveOffset : snapToHalf(rawCurveOffset),
|
||||
)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(wall, snapToHalf(rawCurveOffset))
|
||||
|
||||
if (curveDragState.currentCurveOffset === nextCurveOffset) {
|
||||
return
|
||||
@@ -8117,9 +8116,7 @@ export function FloorplanPanel({
|
||||
curveDragState.currentCurveOffset = nextCurveOffset
|
||||
setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset })
|
||||
setCursorPoint(snappedPoint)
|
||||
if (!bypassSnap) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
const commitGuideInteraction = (event: PointerEvent) => {
|
||||
@@ -8314,6 +8311,7 @@ export function FloorplanPanel({
|
||||
updateNode,
|
||||
wallById,
|
||||
walls,
|
||||
setCursorPoint,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -8348,10 +8346,7 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const snappedPoint: WallPlanPoint = bypassSnap
|
||||
? planPoint
|
||||
: [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
||||
const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
||||
setCursorPoint(snappedPoint)
|
||||
|
||||
const currentDraft = siteBoundaryDraftRef.current
|
||||
@@ -8364,9 +8359,7 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
if (!bypassSnap) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
|
||||
const nextPolygon = [...currentDraft.polygon]
|
||||
nextPolygon[dragState.vertexIndex] = snappedPoint
|
||||
@@ -8441,11 +8434,11 @@ export function FloorplanPanel({
|
||||
exitSiteEditingToSelect,
|
||||
getPlanPointFromClientPoint,
|
||||
setSiteBoundaryLivePreview,
|
||||
shiftPressed,
|
||||
site,
|
||||
siteBoundaryWorldPolygon,
|
||||
siteVertexDragState,
|
||||
updateNode,
|
||||
setCursorPoint,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -8504,7 +8497,14 @@ export function FloorplanPanel({
|
||||
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
},
|
||||
[fittedViewport, floorplanSceneRotationDeg, floorplanUserRotationDeg, viewport],
|
||||
[
|
||||
fittedViewport,
|
||||
floorplanSceneRotationDeg,
|
||||
floorplanUserRotationDeg,
|
||||
viewport,
|
||||
setFloorplanCursorPosition,
|
||||
setCursorPoint,
|
||||
],
|
||||
)
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
@@ -8570,7 +8570,7 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
const wallNode = useScene.getState().nodes[wallId as AnyNodeId]
|
||||
if (!wallNode || wallNode.type !== 'wall') {
|
||||
if (wallNode?.type !== 'wall') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8643,7 +8643,7 @@ export function FloorplanPanel({
|
||||
const emitFloorplanCeilingLeave = useCallback((ceilingId: string | null) => {
|
||||
if (!ceilingId) return
|
||||
const ceilingNode = useScene.getState().nodes[ceilingId as AnyNodeId]
|
||||
if (!ceilingNode || ceilingNode.type !== 'ceiling') return
|
||||
if (ceilingNode?.type !== 'ceiling') return
|
||||
|
||||
emitter.emit('ceiling:leave', {
|
||||
node: ceilingNode,
|
||||
@@ -8834,8 +8834,7 @@ export function FloorplanPanel({
|
||||
// chip on the right): `grid` quantizes via `snapToHalf` (whose step is
|
||||
// 0 — i.e. off — in any non-grid mode), `angles` locks to 15° rays from
|
||||
// the previous vertex, `lines` pulls onto wall corners / alignment
|
||||
// guides, `off` is free. No Shift hold-to-bypass; Alt forces (skips
|
||||
// alignment).
|
||||
// guides, `off` is free. Alignment follows the magnetic snap mode.
|
||||
const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive()
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
@@ -8846,7 +8845,6 @@ export function FloorplanPanel({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
|
||||
@@ -8861,9 +8859,9 @@ export function FloorplanPanel({
|
||||
// Roof is placed as a footprint (no directional draw → polygon context:
|
||||
// grid / lines / off, no angle lock). Mode-driven, matching the chip:
|
||||
// `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid
|
||||
// modes), `lines` pulls onto alignment, `off` is free. Alt forces.
|
||||
// modes), `lines` pulls onto alignment, `off` is free.
|
||||
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
|
||||
bypass: event.altKey || !isMagneticSnapActive(),
|
||||
bypass: !isMagneticSnapActive(),
|
||||
})
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
@@ -8930,7 +8928,7 @@ export function FloorplanPanel({
|
||||
// Mode-driven (matches the chip): `grid` quantizes (`snapToHalf`'s step
|
||||
// is 0 in non-grid modes), `angles` locks 15° rays from the previous
|
||||
// vertex, `lines` snaps onto wall corners / alignment guides, `off` is
|
||||
// free. No Shift bypass; Alt forces (skips alignment).
|
||||
// free. Alignment follows the magnetic snap mode.
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive()
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
@@ -8943,14 +8941,13 @@ export function FloorplanPanel({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
} else if (angleSnap) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
|
||||
bypass: event.altKey || !isMagneticSnapActive(),
|
||||
bypass: !isMagneticSnapActive(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9025,9 +9022,8 @@ export function FloorplanPanel({
|
||||
// builder needs a wall for `ctx.parent`, so we publish the opening on
|
||||
// a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to
|
||||
// `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it
|
||||
// through the real `def.floorplan` builder. Shift bypasses grid snap.
|
||||
const snappedPoint =
|
||||
shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||
// through the real `def.floorplan` builder.
|
||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||
showOpeningGhost(snappedPoint)
|
||||
}
|
||||
return
|
||||
@@ -9039,8 +9035,7 @@ export function FloorplanPanel({
|
||||
// routing through `grid:move`, which would otherwise be processed
|
||||
// by the floor strategy and drop the item to floor height.
|
||||
if (isCeilingItemPlacementActive) {
|
||||
const bypassSnap = shiftPressed || event.shiftKey
|
||||
const snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
)
|
||||
@@ -9060,7 +9055,7 @@ export function FloorplanPanel({
|
||||
// this exclusion the catch-all would emit `grid:move` and re-drive the
|
||||
// 3D MoveDoorTool's free-follow, fighting the overlay again.
|
||||
if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
@@ -9172,12 +9167,15 @@ export function FloorplanPanel({
|
||||
roofDraftStart,
|
||||
elevatorResizeDragState,
|
||||
siteVertexDragState,
|
||||
shiftPressed,
|
||||
surfaceSize.height,
|
||||
surfaceSize.width,
|
||||
viewBox.height,
|
||||
viewBox.width,
|
||||
walls,
|
||||
setCursorPoint,
|
||||
setDraftEnd,
|
||||
setRoofDraftEnd,
|
||||
setFenceDraftEnd,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -9206,7 +9204,7 @@ export function FloorplanPanel({
|
||||
setSlabDraftPoints((currentPoints) => [...currentPoints, point])
|
||||
setCursorPoint(point)
|
||||
},
|
||||
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints],
|
||||
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints, setCursorPoint],
|
||||
)
|
||||
const handleSlabPlacementConfirm = useCallback(
|
||||
(point?: WallPlanPoint) => {
|
||||
@@ -9256,7 +9254,7 @@ export function FloorplanPanel({
|
||||
setCeilingDraftPoints((currentPoints) => [...currentPoints, point])
|
||||
setCursorPoint(point)
|
||||
},
|
||||
[ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel],
|
||||
[ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel, setCursorPoint],
|
||||
)
|
||||
const handleCeilingPlacementConfirm = useCallback(
|
||||
(point?: WallPlanPoint) => {
|
||||
@@ -9306,7 +9304,7 @@ export function FloorplanPanel({
|
||||
setZoneDraftPoints((currentPoints) => [...currentPoints, point])
|
||||
setCursorPoint(point)
|
||||
},
|
||||
[clearDraft, createZoneOnCurrentLevel, zoneDraftPoints],
|
||||
[clearDraft, createZoneOnCurrentLevel, zoneDraftPoints, setCursorPoint],
|
||||
)
|
||||
const handleZonePlacementConfirm = useCallback(
|
||||
(point?: WallPlanPoint) => {
|
||||
@@ -9388,7 +9386,7 @@ export function FloorplanPanel({
|
||||
setDraftEnd(nextStart)
|
||||
setCursorPoint(nextStart)
|
||||
},
|
||||
[clearWallPlacementDraft, draftStart, wallChainFirstVertex],
|
||||
[clearWallPlacementDraft, draftStart, wallChainFirstVertex, setDraftEnd, setCursorPoint],
|
||||
)
|
||||
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
|
||||
ceilingPolygons: displayCeilingPolygons,
|
||||
@@ -9604,6 +9602,7 @@ export function FloorplanPanel({
|
||||
unit,
|
||||
visibleZonePolygons,
|
||||
emitFloorplanGridEvent,
|
||||
setCursorPoint,
|
||||
],
|
||||
)
|
||||
const handleSvgClick = useCallback(
|
||||
@@ -9642,7 +9641,6 @@ export function FloorplanPanel({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
emitFloorplanGridEvent('double-click', snappedPoint, event)
|
||||
@@ -9657,7 +9655,6 @@ export function FloorplanPanel({
|
||||
rawPoint: planPoint,
|
||||
fallbackPoint,
|
||||
levelId,
|
||||
altKey: event.altKey,
|
||||
align: !angleSnap,
|
||||
}).point
|
||||
// Slab is registry-driven: forward the double-click so the 3D tool
|
||||
@@ -10121,7 +10118,7 @@ export function FloorplanPanel({
|
||||
})
|
||||
setCursorPoint(toWallPlanPoint(vertexPoint))
|
||||
},
|
||||
[displaySitePolygon, setSiteBoundaryLivePreview],
|
||||
[displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint],
|
||||
)
|
||||
const handleSiteVertexDoubleClick = useCallback(
|
||||
(
|
||||
@@ -10205,7 +10202,7 @@ export function FloorplanPanel({
|
||||
})
|
||||
setCursorPoint(insertedPoint)
|
||||
},
|
||||
[displaySitePolygon, setSiteBoundaryLivePreview],
|
||||
[displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint],
|
||||
)
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
@@ -10224,7 +10221,7 @@ export function FloorplanPanel({
|
||||
emitFloorplanWallLeave(hoveredWallIdRef.current)
|
||||
hoveredWallIdRef.current = null
|
||||
}
|
||||
}, [emitFloorplanWallLeave, siteVertexDragState])
|
||||
}, [emitFloorplanWallLeave, siteVertexDragState, setCursorPoint])
|
||||
|
||||
// Lightweight flag that mirrors the conditions under which
|
||||
// FloorplanCursorIndicatorOverlay renders — used to gate cursor-position
|
||||
@@ -10274,6 +10271,7 @@ export function FloorplanPanel({
|
||||
isSpacePanPressed,
|
||||
elevatorResizeDragState,
|
||||
siteVertexDragState,
|
||||
setFloorplanCursorPosition,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -10281,7 +10279,7 @@ export function FloorplanPanel({
|
||||
setFloorplanCursorPosition(null)
|
||||
setHoveredGuideCorner(null)
|
||||
handlePointerLeave()
|
||||
}, [handlePointerLeave])
|
||||
}, [handlePointerLeave, setFloorplanCursorPosition])
|
||||
|
||||
const handleMarqueePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<SVGRectElement>) => {
|
||||
@@ -10317,7 +10315,12 @@ export function FloorplanPanel({
|
||||
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
},
|
||||
[getPlanPointFromClientPoint, syncPreviewSelectedIds],
|
||||
[
|
||||
getPlanPointFromClientPoint,
|
||||
syncPreviewSelectedIds,
|
||||
setFloorplanCursorPosition,
|
||||
setCursorPoint,
|
||||
],
|
||||
)
|
||||
|
||||
const handleMarqueePointerMove = useCallback(
|
||||
@@ -10370,7 +10373,13 @@ export function FloorplanPanel({
|
||||
// marquee overlay leaf, never this panel.
|
||||
useFloorplanMarquee.getState().setCurrent(snappedPoint)
|
||||
},
|
||||
[getFloorplanSelectionIdsInBounds, getPlanPointFromClientPoint, syncPreviewSelectedIds],
|
||||
[
|
||||
getFloorplanSelectionIdsInBounds,
|
||||
getPlanPointFromClientPoint,
|
||||
syncPreviewSelectedIds,
|
||||
setFloorplanCursorPosition,
|
||||
setCursorPoint,
|
||||
],
|
||||
)
|
||||
|
||||
const handleMarqueePointerUp = useCallback(
|
||||
@@ -10441,7 +10450,7 @@ export function FloorplanPanel({
|
||||
syncPreviewSelectedIds([])
|
||||
setCursorPoint(null)
|
||||
},
|
||||
[syncPreviewSelectedIds],
|
||||
[syncPreviewSelectedIds, setFloorplanCursorPosition, setCursorPoint],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -10456,7 +10465,13 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
setFloorplanCursorPosition(null)
|
||||
}, [isMarqueeSelectionToolActive, mode, syncPreviewSelectedIds])
|
||||
}, [
|
||||
isMarqueeSelectionToolActive,
|
||||
mode,
|
||||
syncPreviewSelectedIds,
|
||||
setFloorplanCursorPosition,
|
||||
setCursorPoint,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'delete') {
|
||||
@@ -10554,7 +10569,7 @@ export function FloorplanPanel({
|
||||
}
|
||||
|
||||
const buildingNode = sceneNodes[nextBuildingId]
|
||||
if (!buildingNode || buildingNode.type !== 'building') {
|
||||
if (buildingNode?.type !== 'building') {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -10793,7 +10808,7 @@ export function FloorplanPanel({
|
||||
</form>
|
||||
)}
|
||||
|
||||
{(!levelNode || levelNode.type !== 'level') && !hasAmbientBuildingLevel ? (
|
||||
{levelNode?.type !== 'level' && !hasAmbientBuildingLevel ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
|
||||
Switch to a building level to view and edit the floorplan.
|
||||
</div>
|
||||
|
||||
@@ -99,6 +99,8 @@ export type HandleArrowProps = {
|
||||
onPointerLeave?: PointerHandler
|
||||
// Extrude the slimmer wall-handle chevron profile (chevron shape only).
|
||||
thin?: boolean
|
||||
// Render the corner-picker disc as a smooth circle instead of a hexagon.
|
||||
round?: boolean
|
||||
}
|
||||
|
||||
function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
|
||||
@@ -336,7 +338,11 @@ export function createEndpointHitAreaGeometry(radius: number) {
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) {
|
||||
// Hexagon (6 segments) by default; a smooth circle (32 segments) when `round`.
|
||||
const CORNER_DISC_SEGMENTS = 6
|
||||
const CORNER_DISC_ROUND_SEGMENTS = 32
|
||||
|
||||
function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round = false) {
|
||||
if (shape === 'chevron') return createArrowHandleGeometry(thin)
|
||||
if (shape === 'cross') return createMoveCrossHandleGeometry()
|
||||
if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
|
||||
@@ -345,17 +351,23 @@ function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) {
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
|
||||
const geometry = new CircleGeometry(
|
||||
CORNER_HEX_RADIUS,
|
||||
round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
|
||||
)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createHandleArrowHitGeometry(shape: HandleArrowShape) {
|
||||
function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) {
|
||||
if (shape === 'chevron') return createArrowHitAreaGeometry()
|
||||
if (shape === 'cross') return createMoveCrossHitAreaGeometry()
|
||||
if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry()
|
||||
if (shape === 'tracker') return createTrackerHitAreaGeometry()
|
||||
const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6)
|
||||
const geometry = new CircleGeometry(
|
||||
CORNER_HEX_RADIUS,
|
||||
round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS,
|
||||
)
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
@@ -482,10 +494,17 @@ export function HandleArrow({
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
thin = false,
|
||||
round = false,
|
||||
}: HandleArrowProps) {
|
||||
const visualShape = normalizeHandleArrowShape(shape, cursor)
|
||||
const geometry = useMemo(() => createHandleArrowGeometry(visualShape, thin), [visualShape, thin])
|
||||
const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape])
|
||||
const geometry = useMemo(
|
||||
() => createHandleArrowGeometry(visualShape, thin, round),
|
||||
[visualShape, thin, round],
|
||||
)
|
||||
const hitGeometry = useMemo(
|
||||
() => createHandleArrowHitGeometry(visualShape, round),
|
||||
[visualShape, round],
|
||||
)
|
||||
const indicatorMaterial = useHandleArrowMaterial(visualShape)
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
const rootRef = useRef<Group>(null)
|
||||
|
||||
@@ -58,6 +58,7 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { EditorLayoutV2 } from './editor-layout-v2'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { FenceTangentLines3D } from './fence-tangent-lines-3d'
|
||||
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||
import { FloatingActionMenu } from './floating-action-menu'
|
||||
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
||||
@@ -725,6 +726,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
{!noEditing && <WallOpeningHighlights />}
|
||||
{!noEditing && <SlabHoleHighlights />}
|
||||
{!noEditing && <WallMoveSideHandles />}
|
||||
{!noEditing && <FenceTangentLines3D />}
|
||||
{!noEditing && <FloatingActionMenu />}
|
||||
{!noEditing && <FloatingBuildingActionMenu />}
|
||||
{!isFirstPersonMode && <WallMeasurementLabel />}
|
||||
|
||||
@@ -1295,6 +1295,7 @@ function TapActionArrow({
|
||||
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
||||
const shape = descriptor.shape ?? 'arrow'
|
||||
const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
|
||||
const round = descriptor.round ?? false
|
||||
|
||||
const onActivate = useHandleDrag({
|
||||
kind: 'tap',
|
||||
@@ -1315,6 +1316,7 @@ function TapActionArrow({
|
||||
onHoverChange={setIsHovered}
|
||||
onPointerDown={onActivate}
|
||||
position={position}
|
||||
round={round}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1420,6 +1422,7 @@ function CornerPickerShape({
|
||||
hover,
|
||||
onHoverChange,
|
||||
onPointerDown,
|
||||
round = false,
|
||||
}: {
|
||||
position: readonly [number, number, number]
|
||||
height: number
|
||||
@@ -1428,6 +1431,7 @@ function CornerPickerShape({
|
||||
hover: boolean
|
||||
onHoverChange: (hovered: boolean) => void
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
round?: boolean
|
||||
}) {
|
||||
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height])
|
||||
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
|
||||
@@ -1505,10 +1509,11 @@ function CornerPickerShape({
|
||||
onHoverChange={onHoverChange}
|
||||
onPointerDown={onPointerDown}
|
||||
placement={{ position: [0, 0, 0], baseScale }}
|
||||
round={round}
|
||||
shape="corner-picker"
|
||||
/>
|
||||
<mesh material={ringMaterial} renderOrder={1002} scale={scale}>
|
||||
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
|
||||
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, round ? 32 : 6]} />
|
||||
</mesh>
|
||||
</group>
|
||||
</>
|
||||
|
||||
@@ -57,11 +57,13 @@ import {
|
||||
resolvePaintScopeTargets,
|
||||
slotDisplayLabel,
|
||||
} from '../../lib/paint-scope'
|
||||
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
|
||||
import {
|
||||
resolveNodeSelectionTarget,
|
||||
resolveSelectedIdsForNodeClick,
|
||||
type SelectionModifierKeys,
|
||||
selectionModifiersFromEvent,
|
||||
shouldPreserveSelectedRoofHostTarget,
|
||||
} from '../../lib/selection-routing'
|
||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||
@@ -300,18 +302,21 @@ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode |
|
||||
return bestSegment?.node ?? firstSegment
|
||||
}
|
||||
|
||||
function isInActiveRoofContext(
|
||||
segment: RoofSegmentNode,
|
||||
selectedIds: readonly string[],
|
||||
nodes: Record<string, AnyNode>,
|
||||
): boolean {
|
||||
if (!segment.parentId) return false
|
||||
if (selectedIds.includes(segment.id) || selectedIds.includes(segment.parentId)) return true
|
||||
function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode {
|
||||
if (event.node.type === 'roof') {
|
||||
if (
|
||||
shouldPreserveSelectedRoofHostTarget({
|
||||
node: event.node,
|
||||
selectedIds: useViewer.getState().selection.selectedIds,
|
||||
armedRoofId: useEditor.getState().roofHostDragArmedId,
|
||||
})
|
||||
) {
|
||||
return event.node
|
||||
}
|
||||
return resolveRoofSegmentSelectionTarget(event) ?? event.node
|
||||
}
|
||||
|
||||
return selectedIds.some((selectedId) => {
|
||||
const selectedNode = nodes[selectedId]
|
||||
return selectedNode?.type === 'roof-segment' && selectedNode.parentId === segment.parentId
|
||||
})
|
||||
return event.node
|
||||
}
|
||||
|
||||
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
|
||||
@@ -902,7 +907,7 @@ export const SelectionManager = () => {
|
||||
: node.parentId
|
||||
? useScene.getState().nodes[node.parentId as AnyNodeId]
|
||||
: null
|
||||
if (!roofNode || roofNode.type !== 'roof') return null
|
||||
if (roofNode?.type !== 'roof') return null
|
||||
|
||||
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
|
||||
const compatible = role !== null && paintEnabled
|
||||
@@ -1428,7 +1433,7 @@ export const SelectionManager = () => {
|
||||
const activeScope = useInteractionScope.getState().scope
|
||||
if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return
|
||||
|
||||
const node = event.node
|
||||
const node = resolveSelectModeNodeTarget(event)
|
||||
|
||||
// A ceiling is selectable only through its corner handles, never via
|
||||
// the `ceiling-grid` body mesh. When the grid is revealed (ceiling
|
||||
@@ -1481,18 +1486,6 @@ export const SelectionManager = () => {
|
||||
}, 50)
|
||||
|
||||
let nodeToSelect = node
|
||||
if (node.type === 'roof-segment' && node.parentId) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const parentNode = nodes[node.parentId as AnyNodeId]
|
||||
const selectedIds = useViewer.getState().selection.selectedIds
|
||||
if (
|
||||
parentNode &&
|
||||
parentNode.type === 'roof' &&
|
||||
!isInActiveRoofContext(node, selectedIds, nodes)
|
||||
) {
|
||||
nodeToSelect = parentNode
|
||||
}
|
||||
}
|
||||
if (node.type === 'stair-segment' && node.parentId) {
|
||||
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
|
||||
if (parentNode && parentNode.type === 'stair') {
|
||||
@@ -1670,7 +1663,7 @@ export const SelectionManager = () => {
|
||||
// surface move tools keep tracking — but the select-hover outline must
|
||||
// stay put, so don't repaint under the cursor mid-drag.
|
||||
if (useViewer.getState().inputDragging) return
|
||||
const node = event.node
|
||||
const node = resolveSelectModeNodeTarget(event)
|
||||
const currentPhase = useEditor.getState().phase
|
||||
|
||||
// Ignore site/building if we are already inside a building
|
||||
@@ -1698,17 +1691,14 @@ export const SelectionManager = () => {
|
||||
|
||||
const onLeave = (event: NodeEvent) => {
|
||||
if (useViewer.getState().inputDragging) return
|
||||
const nodeId = event?.node?.id
|
||||
const nodeId = resolveSelectModeNodeTarget(event)?.id
|
||||
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
}
|
||||
}
|
||||
|
||||
const onDoubleClick = (event: NodeEvent) => {
|
||||
let node = event.node
|
||||
if (node.type === 'roof') {
|
||||
node = resolveRoofSegmentSelectionTarget(event) ?? node
|
||||
}
|
||||
let node = resolveSelectModeNodeTarget(event)
|
||||
|
||||
const currentPhase = useEditor.getState().phase
|
||||
|
||||
@@ -1893,6 +1883,8 @@ export const SelectionManager = () => {
|
||||
const SelectionStateSync = () => {
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
|
||||
const roofHostDragArmedId = useEditor((s) => s.roofHostDragArmedId)
|
||||
const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId)
|
||||
const singleSelectedId = useViewer((s) =>
|
||||
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
|
||||
)
|
||||
@@ -1925,6 +1917,12 @@ const SelectionStateSync = () => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!roofHostDragArmedId) return
|
||||
if (singleSelectedId === roofHostDragArmedId) return
|
||||
setRoofHostDragArmedId(null)
|
||||
}, [roofHostDragArmedId, setRoofHostDragArmedId, singleSelectedId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMaterialTarget) return
|
||||
|
||||
@@ -2152,7 +2150,11 @@ const EditorOutlinerSync = () => {
|
||||
if (!nodes[hoveredId as AnyNodeId]) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
} else {
|
||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||
const hoveredNode = nodes[hoveredId as AnyNodeId]
|
||||
const obj =
|
||||
hoveredNode?.type === 'roof-segment'
|
||||
? (getHoveredRoofSegmentOutlineProxy(hoveredId) ?? sceneRegistry.nodes.get(hoveredId))
|
||||
: sceneRegistry.nodes.get(hoveredId)
|
||||
if (obj?.parent) outliner.hoveredObjects.push(obj)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ function useLevelChildren<TNode extends AnyNode>(
|
||||
}
|
||||
|
||||
const levelNode = state.nodes[levelId]
|
||||
if (!levelNode || levelNode.type !== 'level') {
|
||||
if (levelNode?.type !== 'level') {
|
||||
return [] as TNode[]
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function useFloorplanSceneData({
|
||||
}
|
||||
|
||||
const buildingNode = state.nodes[currentBuildingId]
|
||||
if (!buildingNode || buildingNode.type !== 'building') {
|
||||
if (buildingNode?.type !== 'building') {
|
||||
return [] as LevelNode[]
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export function useFloorplanSceneData({
|
||||
}
|
||||
|
||||
const nextLevelNode = state.nodes[levelId]
|
||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
||||
if (nextLevelNode?.type !== 'level') {
|
||||
return [] as RoofNode[]
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export function useFloorplanSceneData({
|
||||
}
|
||||
|
||||
const nextLevelNode = state.nodes[levelId]
|
||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
||||
if (nextLevelNode?.type !== 'level') {
|
||||
return [] as OpeningNode[]
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export function useFloorplanSceneData({
|
||||
}
|
||||
|
||||
const nextLevelNode = state.nodes[levelId]
|
||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
||||
if (nextLevelNode?.type !== 'level') {
|
||||
return [] as AnyNode[]
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ import { EDITOR_LAYER } from '../../lib/constants'
|
||||
* intersection → ✕ cross wall body (edge) → circle
|
||||
*
|
||||
* Subscribes to the shared `useWallSnapIndicator` store (published by the wall
|
||||
* draft + endpoint-move tools). Shares the indigo accent of
|
||||
* `Alignment3DGuideLayer` for visual consistency.
|
||||
* draft + endpoint-move tools). The vertical mouse pillar and the corner
|
||||
* (endpoint) square are green; the other floor glyphs are indigo.
|
||||
*
|
||||
* The point carries only XZ (building-local plan coords); like the alignment
|
||||
* guides it's lifted to the active level's building-local Y each frame so it
|
||||
@@ -43,6 +43,7 @@ import { EDITOR_LAYER } from '../../lib/constants'
|
||||
*/
|
||||
|
||||
const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent
|
||||
const MARKER_GREEN = 0x22_c5_5e // green-500 — vertical mouse marker + corner (endpoint) glyph
|
||||
const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
|
||||
const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
|
||||
const MARKER = 0.13 // world-meter base size of the floor glyph
|
||||
@@ -66,6 +67,14 @@ const beaconMaterial = new MeshBasicNodeMaterial({
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
})
|
||||
const greenMarkerMaterial = new MeshBasicNodeMaterial({
|
||||
color: MARKER_GREEN,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
})
|
||||
const wallTopHighlightMaterial = new MeshBasicNodeMaterial({
|
||||
color: BEACON_COLOR,
|
||||
depthTest: false,
|
||||
@@ -121,7 +130,7 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
|
||||
<mesh
|
||||
geometry={PILLAR_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
material={beaconMaterial}
|
||||
material={greenMarkerMaterial}
|
||||
position={[point.x, BEACON_HEIGHT / 2, point.z]}
|
||||
renderOrder={1002}
|
||||
/>
|
||||
@@ -235,7 +244,7 @@ function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }
|
||||
<mesh
|
||||
geometry={FLAT_BOX_GEOMETRY}
|
||||
layers={EDITOR_LAYER}
|
||||
material={beaconMaterial}
|
||||
material={greenMarkerMaterial}
|
||||
position={[x, y, z]}
|
||||
renderOrder={1001}
|
||||
scale={[MARKER * 2, 1, MARKER * 2]}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,11 @@ import {
|
||||
resolveAlignment,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
||||
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import usePlacementPreview from '../../../store/use-placement-preview'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
DEFAULT_ANGLE_STEP,
|
||||
FenceNode,
|
||||
getTwoPointFenceCurveTangents,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
isCurvedWall,
|
||||
@@ -214,3 +215,44 @@ export function createFenceOnCurrentLevel(
|
||||
|
||||
return fence
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a smooth spline fence from a list of drawn control points. The
|
||||
* centerline becomes a Catmull-Rom curve through `path`; `start`/`end` are
|
||||
* pinned to the first/last point so endpoint handles, bbox, and miter
|
||||
* references stay valid. Requires >= 2 points spanning a usable distance.
|
||||
*/
|
||||
export function createSplineFenceOnCurrentLevel(
|
||||
path: FencePlanPoint[],
|
||||
tangents = getTwoPointFenceCurveTangents(path),
|
||||
): FenceNode | null {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
if (!currentLevelId || path.length < 2) {
|
||||
return null
|
||||
}
|
||||
const start = path[0]!
|
||||
const end = path[path.length - 1]!
|
||||
// A degenerate single-point-ish path (all clicks on one spot) is rejected
|
||||
// the same way a too-short straight segment is.
|
||||
if (!isSegmentLongEnough(start, end) && path.length < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
|
||||
const defaults = useEditor.getState().toolDefaults.fence ?? {}
|
||||
const fence = FenceNode.parse({
|
||||
...defaults,
|
||||
name: `Fence ${fenceCount + 1}`,
|
||||
start,
|
||||
end,
|
||||
path,
|
||||
tangents,
|
||||
})
|
||||
|
||||
createNode(fence, currentLevelId)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
|
||||
return fence
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
@@ -47,7 +46,11 @@ import {
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { formatLinearMeasurement } from '../../../lib/measurements'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||
import {
|
||||
projectAlignmentGuidesWorldToActiveBuildingLocal,
|
||||
resolveAlignmentForActiveBuilding,
|
||||
} from '../../../lib/world-grid-snap'
|
||||
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
||||
import useEditor, { isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import useFacingPose from '../../../store/use-facing-pose'
|
||||
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
||||
@@ -856,7 +859,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
alignX = ar.snap.dx
|
||||
alignZ = ar.snap.dz
|
||||
}
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
useAlignmentGuides
|
||||
.getState()
|
||||
.set(projectAlignmentGuidesWorldToActiveBuildingLocal(ar.guides))
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
@@ -32,6 +31,7 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
|
||||
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { resolveSnapFlags } from '../../../lib/snapping-mode'
|
||||
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
||||
import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import useFacingPose from '../../../store/use-facing-pose'
|
||||
import { swallowNextClick } from '../../editor/node-arrow-handles'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
@@ -7,33 +8,93 @@ import {
|
||||
type LevelNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
resolveBuildingForLevel,
|
||||
sceneRegistry,
|
||||
snapScalar,
|
||||
useScene,
|
||||
type WallNode,
|
||||
wallSegmentAnchors,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { clearSurfacePlanSnapFeedback, resolveSurfacePlanPointSnap } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
|
||||
import {
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
type Line,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
resolveAlignmentForActiveBuilding,
|
||||
snapWorldXZForActiveBuilding,
|
||||
} from '../../../lib/world-grid-snap'
|
||||
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
|
||||
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const DEFAULT_WALL_HEIGHT = 0.5
|
||||
const DEFAULT_PITCH_DEG = 40
|
||||
const GRID_OFFSET = 0.02
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
function snapToActiveGrid(value: number): number {
|
||||
return snapScalar(value, useEditor.getState().gridSnapStep)
|
||||
// Walls that are direct children of a level.
|
||||
function getLevelWalls(
|
||||
levelId: string | null,
|
||||
nodes: Readonly<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')
|
||||
}
|
||||
|
||||
// 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 feed straight into both the alignment pool
|
||||
// and the magnetic wall-snap pipeline — letting a roof drawn on the upper
|
||||
// floor snap onto the wall corners of the floor below.
|
||||
function getBelowLevelWalls(
|
||||
currentLevelId: string | null,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): WallNode[] {
|
||||
if (!currentLevelId) return []
|
||||
const currentLevel = nodes[currentLevelId]
|
||||
if (currentLevel?.type !== 'level') return []
|
||||
const buildingId = resolveBuildingForLevel(currentLevel.id, 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)
|
||||
}
|
||||
|
||||
// Current-level + floor-below walls — the magnetic snap targets the roof draft
|
||||
// locks onto (corners, midpoints, crossings, wall bodies), matching the wall
|
||||
// tool. Same coordinate frame, so no transform is needed.
|
||||
function getRoofSnapWalls(
|
||||
currentLevelId: string | null,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): WallNode[] {
|
||||
return [...getLevelWalls(currentLevelId, nodes), ...getBelowLevelWalls(currentLevelId, nodes)]
|
||||
}
|
||||
|
||||
// Current-level alignment anchors plus the floor-below wall corners.
|
||||
function collectRoofAlignmentAnchors(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
currentLevelId: string | null,
|
||||
): AlignmentAnchor[] {
|
||||
return [
|
||||
...collectAlignmentAnchors(nodes, '', currentLevelId),
|
||||
...getBelowLevelWalls(currentLevelId, nodes).flatMap((wall) =>
|
||||
wallSegmentAnchors(wall.id, wall.start, wall.end, wall.thickness),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,14 +209,168 @@ type PreviewState = {
|
||||
levelY: number
|
||||
}
|
||||
|
||||
function buildRoofGhostGeometry(
|
||||
width: number,
|
||||
depth: number,
|
||||
wallHeight: number,
|
||||
pitchDeg: number,
|
||||
) {
|
||||
const safeWidth = Math.max(width, 0.1)
|
||||
const safeDepth = Math.max(depth, 0.1)
|
||||
const halfWidth = safeWidth / 2
|
||||
const halfDepth = safeDepth / 2
|
||||
const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth
|
||||
|
||||
const vertices = [
|
||||
// Front slope
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
-halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
|
||||
// Back slope
|
||||
-halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
|
||||
-halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
|
||||
// Left gable
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
-halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
|
||||
// Right gable
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
]
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3))
|
||||
geometry.computeVertexNormals()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function buildRoofGhostEdges(width: number, depth: number, wallHeight: number, pitchDeg: number) {
|
||||
const safeWidth = Math.max(width, 0.1)
|
||||
const safeDepth = Math.max(depth, 0.1)
|
||||
const halfWidth = safeWidth / 2
|
||||
const halfDepth = safeDepth / 2
|
||||
const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth
|
||||
|
||||
const vertices = [
|
||||
// Base rectangle
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
|
||||
// Ridge + gable edges
|
||||
-halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
-halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
-halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
-halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
-halfDepth,
|
||||
halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
halfWidth,
|
||||
ridgeHeight,
|
||||
0,
|
||||
halfWidth,
|
||||
wallHeight,
|
||||
halfDepth,
|
||||
]
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
export const RoofTool: React.FC = () => {
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const outlineRef = useRef<Line>(null!)
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
|
||||
const selectedIdsRef = useRef(selectedIds)
|
||||
useEffect(() => {
|
||||
@@ -179,44 +394,38 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
outlineRef.current.geometry = new BufferGeometry()
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each roof commits. Both corners of the rectangle align.
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
|
||||
// Snap the drafted corner onto another object's nearest real anchor and
|
||||
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
|
||||
// point: resolving against the grid point would only ever catch anchors
|
||||
// that happen to sit on a grid line, so off-grid items (furniture, angled
|
||||
// walls) would never surface a guide. The matched axis locks exactly to the
|
||||
// candidate's coordinate; the other axis keeps its grid snap. Alignment runs
|
||||
// only when the magnetic (lines) snapping mode is active.
|
||||
const alignPoint = (
|
||||
gridX: number,
|
||||
gridZ: number,
|
||||
rawX: number,
|
||||
rawZ: number,
|
||||
bypass: boolean,
|
||||
): [number, number] => {
|
||||
if (bypass || alignmentCandidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return [gridX, gridZ]
|
||||
}
|
||||
const ar = resolveAlignmentForActiveBuilding({
|
||||
moving: [{ nodeId: '__roof-draft__', kind: 'corner', x: rawX, z: rawZ }],
|
||||
// Alignment candidates — anchors of every alignable object on the active
|
||||
// level plus the wall corners of the floor directly below, so a roof drawn
|
||||
// on the upper floor aligns to the walls beneath it. Refreshed after each
|
||||
// roof commits. Both corners of the rectangle align.
|
||||
let alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId)
|
||||
|
||||
// Resolve a grid:move/click into the drafted corner via the shared surface
|
||||
// snap pipeline: magnetic lock onto wall corners / midpoints / crossings /
|
||||
// bodies on the active level + floor below (raising the green beacon),
|
||||
// falling back to alignment guides, then to the world-grid snap. The same
|
||||
// path the slab/ceiling tools use, so the beacon and coloring match. The
|
||||
// pipeline reads the snapping mode itself (Shift bypass, magnetic on/off),
|
||||
// so this tool never inspects the flags. `levelId` is intentionally omitted
|
||||
// so the explicit floor-below `walls` aren't filtered back out.
|
||||
const resolveDraftPoint = (event: GridEvent): [number, number] => {
|
||||
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||
const gridFallback: [number, number] = isGridSnapActive()
|
||||
? snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
: rawPoint
|
||||
const nodes = useScene.getState().nodes
|
||||
return resolveSurfacePlanPointSnap({
|
||||
rawPoint,
|
||||
fallbackPoint: gridFallback,
|
||||
walls: getRoofSnapWalls(currentLevelId, nodes),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (ar.guides.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return [gridX, gridZ]
|
||||
}
|
||||
useAlignmentGuides.getState().set(ar.guides)
|
||||
let x = gridX
|
||||
let z = gridZ
|
||||
for (const guide of ar.guides) {
|
||||
if (guide.axis === 'x') x = guide.coord
|
||||
else z = guide.coord
|
||||
}
|
||||
return [x, z]
|
||||
movingId: '__roof-draft__',
|
||||
highlightWalls: true,
|
||||
}).point
|
||||
}
|
||||
|
||||
const updateOutline = (
|
||||
@@ -241,24 +450,7 @@ export const RoofTool: React.FC = () => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to drag every roof corner off the visible grid. Snapping follows
|
||||
// the global mode (grid quantize / lines alignment); Off keeps the raw
|
||||
// cursor. Shift cycles the mode centrally — this tool never reads it.
|
||||
const snapped: [number, number] = isGridSnapActive()
|
||||
? snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
: [event.localPosition[0], event.localPosition[2]]
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
snapped[0],
|
||||
snapped[1],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
!isMagneticSnapActive(),
|
||||
)
|
||||
const [gridX, gridZ] = resolveDraftPoint(event)
|
||||
const y = event.localPosition[1]
|
||||
|
||||
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
|
||||
@@ -291,23 +483,7 @@ export const RoofTool: React.FC = () => {
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// World-grid snap projected into building-local; rotated buildings
|
||||
// used to drag every roof corner off the visible grid. Snapping follows
|
||||
// the global mode; Off keeps the raw cursor.
|
||||
const snapped: [number, number] = isGridSnapActive()
|
||||
? snapWorldXZForActiveBuilding(
|
||||
event.position[0],
|
||||
event.position[2],
|
||||
useEditor.getState().gridSnapStep,
|
||||
).local
|
||||
: [event.localPosition[0], event.localPosition[2]]
|
||||
const [gridX, gridZ] = alignPoint(
|
||||
snapped[0],
|
||||
snapped[1],
|
||||
event.localPosition[0],
|
||||
event.localPosition[2],
|
||||
!isMagneticSnapActive(),
|
||||
)
|
||||
const [gridX, gridZ] = resolveDraftPoint(event)
|
||||
const y = event.localPosition[1]
|
||||
|
||||
if (corner1Ref.current) {
|
||||
@@ -322,8 +498,8 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
corner1Ref.current = null
|
||||
outlineRef.current.visible = false
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
|
||||
useAlignmentGuides.getState().clear()
|
||||
alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId)
|
||||
clearSurfacePlanSnapFeedback()
|
||||
} else {
|
||||
corner1Ref.current = [gridX, y, gridZ]
|
||||
sfxEmitter.emit('sfx:structure-build-start')
|
||||
@@ -341,7 +517,7 @@ export const RoofTool: React.FC = () => {
|
||||
outlineRef.current.visible = false
|
||||
setPreview((prev) => ({ ...prev, corner1: null }))
|
||||
}
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearSurfacePlanSnapFeedback()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
@@ -352,7 +528,7 @@ export const RoofTool: React.FC = () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearSurfacePlanSnapFeedback()
|
||||
|
||||
corner1Ref.current = null
|
||||
}
|
||||
@@ -369,6 +545,34 @@ export const RoofTool: React.FC = () => {
|
||||
return { length, width, centerX, centerZ }
|
||||
}, [corner1, cursorPosition])
|
||||
|
||||
const roofGhostGeometry = useMemo(() => {
|
||||
if (!previewDimensions) return null
|
||||
return buildRoofGhostGeometry(
|
||||
previewDimensions.length,
|
||||
previewDimensions.width,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
DEFAULT_PITCH_DEG,
|
||||
)
|
||||
}, [previewDimensions])
|
||||
|
||||
const roofGhostEdges = useMemo(() => {
|
||||
if (!previewDimensions) return null
|
||||
return buildRoofGhostEdges(
|
||||
previewDimensions.length,
|
||||
previewDimensions.width,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
DEFAULT_PITCH_DEG,
|
||||
)
|
||||
}, [previewDimensions])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
roofGhostGeometry?.dispose()
|
||||
roofGhostEdges?.dispose()
|
||||
},
|
||||
[roofGhostEdges, roofGhostGeometry],
|
||||
)
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere ref={cursorRef} />
|
||||
@@ -402,21 +606,34 @@ export const RoofTool: React.FC = () => {
|
||||
)}
|
||||
|
||||
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
|
||||
<mesh
|
||||
<group
|
||||
layers={EDITOR_LAYER}
|
||||
position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.1}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{roofGhostGeometry && (
|
||||
<mesh geometry={roofGhostGeometry} layers={EDITOR_LAYER} renderOrder={1}>
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.16}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
{roofGhostEdges && (
|
||||
<lineSegments geometry={roofGhostEdges} layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<lineBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
</lineSegments>
|
||||
)}
|
||||
</group>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ export function collectSelectableCandidateIds(): string[] {
|
||||
|
||||
if (!levelId) return []
|
||||
const levelNode = nodes[levelId as AnyNodeId] as LevelNode | undefined
|
||||
if (!levelNode || levelNode.type !== 'level') return []
|
||||
if (levelNode?.type !== 'level') return []
|
||||
|
||||
if (phase === 'structure' && structureLayer === 'zones') {
|
||||
for (const childId of levelNode.children) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { furnishTools } from '../../../components/ui/action-menu/furnish-tools'
|
||||
import { tools } from '../../../components/ui/action-menu/structure-tools'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import useWallSnapIndicator from '../../../store/use-wall-snap-indicator'
|
||||
|
||||
interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
|
||||
color?: string
|
||||
@@ -40,6 +41,10 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const catalogCategory = useEditor((s) => s.catalogCategory)
|
||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||
// While a wall snap is active the beacon layer already marks the point
|
||||
// (green square at a corner); hide the cursor's ground dot/ring so it
|
||||
// doesn't sit on top of that glyph.
|
||||
const isSnapping = useWallSnapIndicator((s) => s.point !== null)
|
||||
|
||||
// Find the icon for the current tool
|
||||
let activeToolConfig = null
|
||||
@@ -58,33 +63,35 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
{/* Flat marker on the ground. The bright center dot moves to the tip
|
||||
of the line in `dotAtTip` mode (the placement point hangs above the
|
||||
floor), leaving a faint ring here so the plan position stays read. */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{/* Center dot — at the ground unless the placement point is elevated */}
|
||||
{!dotAtTip && (
|
||||
{!isSnapping && (
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{/* Center dot — at the ground unless the placement point is elevated */}
|
||||
{!dotAtTip && (
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<circleGeometry args={[0.06, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.9}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Outer ring / glow */}
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<circleGeometry args={[0.06, 32]} />
|
||||
<circleGeometry args={[0.2, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.9}
|
||||
opacity={dotAtTip ? 0.2 : 0.25}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Outer ring / glow */}
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<circleGeometry args={[0.2, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={dotAtTip ? 0.2 : 0.25}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
|
||||
{/* Vertical line */}
|
||||
{height > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ export const DEFAULT_STAIR_STEP_COUNT = 10
|
||||
export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
|
||||
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
|
||||
export const DEFAULT_STAIR_THICKNESS = 0.25
|
||||
export const DEFAULT_STAIR_OPENING_OFFSET = 0
|
||||
export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9
|
||||
export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2
|
||||
export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
syncAutoStairOpenings,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
resolveStairDestinationLevel,
|
||||
resolveStairPlacementLevelId,
|
||||
} from '../../../lib/stair-levels'
|
||||
import useAlignmentGuides from '../../../store/use-alignment-guides'
|
||||
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
|
||||
import useFacingPose from '../../../store/use-facing-pose'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
DEFAULT_STAIR_FILL_TO_FLOOR,
|
||||
DEFAULT_STAIR_HEIGHT,
|
||||
DEFAULT_STAIR_LENGTH,
|
||||
DEFAULT_STAIR_OPENING_OFFSET,
|
||||
DEFAULT_STAIR_RAILING_HEIGHT,
|
||||
DEFAULT_STAIR_RAILING_MODE,
|
||||
DEFAULT_STAIR_STEP_COUNT,
|
||||
@@ -140,7 +141,7 @@ function createDefaultStairNode({
|
||||
fromLevelId: levelId,
|
||||
toLevelId: nextLevelId,
|
||||
slabOpeningMode: 'destination',
|
||||
openingOffset: 0.08,
|
||||
openingOffset: DEFAULT_STAIR_OPENING_OFFSET,
|
||||
width: DEFAULT_STAIR_WIDTH,
|
||||
totalRise: DEFAULT_STAIR_HEIGHT,
|
||||
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
||||
|
||||
@@ -12,11 +12,13 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { type ComponentType, lazy, Suspense, useMemo } from 'react'
|
||||
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
||||
import {
|
||||
useControlPointReshape,
|
||||
useEditingHole,
|
||||
useEndpointReshape,
|
||||
useIsCurveReshape,
|
||||
useMovingNode,
|
||||
useReshapingNode,
|
||||
useTangentReshape,
|
||||
} from '../../store/use-interaction-scope'
|
||||
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
|
||||
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
|
||||
@@ -68,6 +70,8 @@ export const ToolManager: React.FC = () => {
|
||||
const movingNode = useMovingNode()
|
||||
const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
|
||||
const endpointReshape = useEndpointReshape()
|
||||
const controlPointReshape = useControlPointReshape()
|
||||
const tangentReshape = useTangentReshape()
|
||||
const isCurveReshape = useIsCurveReshape()
|
||||
const reshapingNode = useReshapingNode()
|
||||
// The endpoint affordance tool's `target` is kind-specific
|
||||
@@ -81,6 +85,18 @@ export const ToolManager: React.FC = () => {
|
||||
? { fence: reshapingNode as FenceNode, endpoint: endpointReshape.endpoint }
|
||||
: { wall: reshapingNode as WallNode, endpoint: endpointReshape.endpoint }
|
||||
}, [endpointReshape, reshapingNode])
|
||||
const controlPointTarget = useMemo(() => {
|
||||
if (!(controlPointReshape && reshapingNode?.type === 'fence')) return null
|
||||
return { fence: reshapingNode as FenceNode, index: controlPointReshape.index }
|
||||
}, [controlPointReshape, reshapingNode])
|
||||
const tangentTarget = useMemo(() => {
|
||||
if (!(tangentReshape && reshapingNode?.type === 'fence')) return null
|
||||
return {
|
||||
fence: reshapingNode as FenceNode,
|
||||
index: tangentReshape.index,
|
||||
side: tangentReshape.side,
|
||||
}
|
||||
}, [reshapingNode, tangentReshape])
|
||||
const editingHole = useEditingHole()
|
||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
@@ -271,6 +287,24 @@ export const ToolManager: React.FC = () => {
|
||||
</Suspense>
|
||||
) : null
|
||||
})()}
|
||||
{controlPointTarget &&
|
||||
(() => {
|
||||
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point')
|
||||
return RegistryAffordance ? (
|
||||
<Suspense fallback={null}>
|
||||
<RegistryAffordance target={controlPointTarget} />
|
||||
</Suspense>
|
||||
) : null
|
||||
})()}
|
||||
{tangentTarget &&
|
||||
(() => {
|
||||
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent')
|
||||
return RegistryAffordance ? (
|
||||
<Suspense fallback={null}>
|
||||
<RegistryAffordance target={tangentTarget} />
|
||||
</Suspense>
|
||||
) : null
|
||||
})()}
|
||||
{showMover && movingNode.type !== 'building' && (
|
||||
<MoveTool
|
||||
onNodeMoved={handlePlacedNodeSelected}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../../lib/snapping-mode'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
||||
import useFenceCurveDraft from '../../../store/use-fence-curve-draft'
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
||||
|
||||
@@ -56,6 +57,7 @@ function ShortcutSequence({ keys }: { keys: string[] }) {
|
||||
// `pointer-events-none`, so clickable chips opt back in.
|
||||
function ChipRow({
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
@@ -63,6 +65,7 @@ function ChipRow({
|
||||
tooltip,
|
||||
}: {
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
icon?: string
|
||||
label: string
|
||||
onClick?: () => void
|
||||
@@ -82,7 +85,9 @@ function ChipRow({
|
||||
)
|
||||
|
||||
if (!onClick) {
|
||||
return <div className={cn(ROW_CLASS, 'items-center')}>{body}</div>
|
||||
return (
|
||||
<div className={cn(ROW_CLASS, 'items-center', disabled && 'opacity-45 saturate-0')}>{body}</div>
|
||||
)
|
||||
}
|
||||
|
||||
const button = (
|
||||
@@ -91,6 +96,7 @@ function ChipRow({
|
||||
className={cn(
|
||||
ROW_CLASS,
|
||||
'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60',
|
||||
disabled && 'opacity-45 saturate-0',
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
@@ -181,6 +187,59 @@ function ContinuationChip({ context }: { context: ContinuationContext }) {
|
||||
)
|
||||
}
|
||||
|
||||
function FenceContinuationChips() {
|
||||
const mode = useEditor((s) => s.getContinuation('fence'))
|
||||
const setContinuation = useEditor((s) => s.setContinuation)
|
||||
const curveStarted = useFenceCurveDraft((s) => s.pointCount > 0)
|
||||
|
||||
const isCurved = mode === 'curved'
|
||||
const straightMode = isCurved ? 'continuous' : mode
|
||||
const straightLabel = straightMode === 'single' ? 'Straight: Single' : 'Straight: Continuous'
|
||||
const straightIcon = straightMode === 'single' ? 'lucide:minus' : 'lucide:waypoints'
|
||||
const typeLabel = isCurved ? 'Type: Curved' : 'Type: Straight'
|
||||
const typeIcon = isCurved ? 'lucide:spline' : 'lucide:minus'
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChipRow
|
||||
ariaLabel={`Fence type: ${isCurved ? 'Curved' : 'Straight'}`}
|
||||
icon={typeIcon}
|
||||
label={typeLabel}
|
||||
onClick={() => setContinuation('fence', isCurved ? 'continuous' : 'curved')}
|
||||
shortcut="T"
|
||||
tooltip="Fence type — click or press T to switch between straight and curved"
|
||||
/>
|
||||
<ChipRow
|
||||
ariaLabel={`Fence continuation: ${straightLabel}`}
|
||||
disabled={isCurved}
|
||||
icon={straightIcon}
|
||||
label={straightLabel}
|
||||
onClick={
|
||||
isCurved
|
||||
? undefined
|
||||
: () => setContinuation('fence', straightMode === 'single' ? 'continuous' : 'single')
|
||||
}
|
||||
shortcut="C"
|
||||
tooltip={
|
||||
isCurved
|
||||
? 'Straight continuation is unavailable while curved fence type is active'
|
||||
: 'Straight fence continuation — click or press C to toggle'
|
||||
}
|
||||
/>
|
||||
{/* Curved fences are committed by a closing gesture rather than per-click,
|
||||
so the finish keys aren't discoverable on their own — surface them, but
|
||||
only once the user has placed a point and a curve is actually in flight. */}
|
||||
{isCurved && curveStarted ? (
|
||||
<ChipRow
|
||||
icon="lucide:circle-check"
|
||||
label="Finish curve (or double-click)"
|
||||
shortcut="Enter"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PAINT_SCOPE_ICONS: Record<PaintScope, string> = {
|
||||
single: 'lucide:square',
|
||||
object: 'lucide:box',
|
||||
@@ -260,7 +319,10 @@ export function ContextualHelperPanel({
|
||||
return (
|
||||
<div className={CONTAINER_CLASS}>
|
||||
{snapContext ? <SnappingChips context={snapContext} /> : null}
|
||||
{continuationContext ? <ContinuationChip context={continuationContext} /> : null}
|
||||
{continuationContext === 'fence' ? <FenceContinuationChips /> : null}
|
||||
{continuationContext && continuationContext !== 'fence' ? (
|
||||
<ContinuationChip context={continuationContext} />
|
||||
) : null}
|
||||
{showPaintScope ? <PaintScopeChip /> : null}
|
||||
{hints.map((hint) => (
|
||||
<div
|
||||
|
||||
@@ -38,6 +38,10 @@ function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] {
|
||||
const action =
|
||||
reshape === 'curve'
|
||||
? 'Curve'
|
||||
: reshape === 'control-point'
|
||||
? 'Move control point'
|
||||
: reshape === 'tangent'
|
||||
? 'Move tangent'
|
||||
: reshape === 'endpoint'
|
||||
? 'Move endpoint'
|
||||
: 'Move corner'
|
||||
|
||||
@@ -17,6 +17,7 @@ const DRAG_MARGIN = 8
|
||||
// Pointer travel (px) below which a header press is treated as a click
|
||||
// (toggles collapse) rather than a drag.
|
||||
const CLICK_SLOP = 4
|
||||
let desktopInspectorCollapsed = true
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), Math.max(min, max))
|
||||
@@ -85,8 +86,21 @@ export function PanelWrapper({
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// The whole panel is collapsed to just its header by default; the chevron
|
||||
// expands it to reveal the inspector body.
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
// expands it to reveal the inspector body. Keep the desktop value shared
|
||||
// across inspector swaps (roof ↔ segment, etc.) so navigating between
|
||||
// related panels preserves whether the user left the inspector open.
|
||||
const [collapsed, setCollapsedState] = useState(desktopInspectorCollapsed)
|
||||
|
||||
const setCollapsed = useCallback(
|
||||
(next: boolean | ((previous: boolean) => boolean)) => {
|
||||
setCollapsedState((previous) => {
|
||||
const resolved = typeof next === 'function' ? next(previous) : next
|
||||
desktopInspectorCollapsed = resolved
|
||||
return resolved
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Drag-to-reposition from the header. `offset` is a translation applied on
|
||||
// top of the default `top-20 right-4` anchor; null until first dragged.
|
||||
|
||||
@@ -28,6 +28,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const setRoofHostDragArmedId = useEditor((state) => state.setRoofHostDragArmedId)
|
||||
const { drag, dropTarget } = useTreeNodeDrag()
|
||||
|
||||
const segments = useScene(
|
||||
@@ -57,8 +58,11 @@ export const RoofTreeNode = memo(function RoofTreeNode({
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
if (!e.metaKey && !e.ctrlKey && !e.shiftKey) {
|
||||
setRoofHostDragArmedId(nodeId)
|
||||
}
|
||||
},
|
||||
[nodeId, setSelection],
|
||||
[nodeId, setRoofHostDragArmedId, setSelection],
|
||||
)
|
||||
|
||||
const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
|
||||
|
||||
@@ -157,6 +157,7 @@ const treeNodeByType: Record<
|
||||
'turbine-vent': RegistryTreeNode,
|
||||
cupola: RegistryTreeNode,
|
||||
'eyebrow-vent': RegistryTreeNode,
|
||||
skylight: RegistryTreeNode,
|
||||
roof: RoofTreeNode,
|
||||
stair: StairTreeNode,
|
||||
door: DoorTreeNode,
|
||||
|
||||
@@ -86,7 +86,7 @@ export function useCeilingEvents() {
|
||||
|
||||
for (const id of ceilingIds) {
|
||||
const node = nodes[id as AnyNodeId] as CeilingNode | undefined
|
||||
if (!node || node.type !== 'ceiling' || node.polygon.length < 3) continue
|
||||
if (node?.type !== 'ceiling' || node.polygon.length < 3) continue
|
||||
if (resolveLevelId(node, nodes) !== activeLevelId) continue
|
||||
const mesh = sceneRegistry.nodes.get(id)
|
||||
if (!mesh) continue
|
||||
|
||||
@@ -92,6 +92,26 @@ export const useKeyboard = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(e.key === 't' || e.key === 'T') &&
|
||||
!e.repeat &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
const context = getActiveContinuationContext()
|
||||
if (context === 'fence') {
|
||||
e.preventDefault()
|
||||
const current = useEditor.getState().getContinuation('fence')
|
||||
useEditor
|
||||
.getState()
|
||||
.setContinuation('fence', current === 'curved' ? 'continuous' : 'curved')
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(e.key === 'c' || e.key === 'C') &&
|
||||
!e.repeat &&
|
||||
@@ -103,6 +123,16 @@ export const useKeyboard = ({
|
||||
const context = getActiveContinuationContext()
|
||||
if (context) {
|
||||
e.preventDefault()
|
||||
if (context === 'fence') {
|
||||
const current = useEditor.getState().getContinuation('fence')
|
||||
if (current !== 'curved') {
|
||||
useEditor
|
||||
.getState()
|
||||
.setContinuation('fence', current === 'single' ? 'continuous' : 'single')
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
return
|
||||
}
|
||||
useEditor.getState().cycleContinuation(context)
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
return
|
||||
|
||||
@@ -80,6 +80,7 @@ export {
|
||||
// shared/segment-angle.ts) once every Stage D port is in.
|
||||
export {
|
||||
createFenceOnCurrentLevel,
|
||||
createSplineFenceOnCurrentLevel,
|
||||
type FencePlanPoint,
|
||||
snapFenceDraftPoint,
|
||||
} from './components/tools/fence/fence-drafting'
|
||||
@@ -353,7 +354,9 @@ export {
|
||||
export { cn } from './lib/utils'
|
||||
export {
|
||||
getActiveBuildingPose,
|
||||
projectAlignmentGuidesWorldToActiveBuildingLocal,
|
||||
resolveAlignmentForActiveBuilding,
|
||||
resolveAlignmentForFloorplanView,
|
||||
snapBuildingLocalToWorldGrid,
|
||||
snapWorldXZForActiveBuilding,
|
||||
} from './lib/world-grid-snap'
|
||||
@@ -377,6 +380,7 @@ export {
|
||||
isMagneticSnapActive,
|
||||
} from './store/use-editor'
|
||||
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
|
||||
export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
|
||||
export {
|
||||
default as useInteractionScope,
|
||||
getEditingHole,
|
||||
|
||||
@@ -17,10 +17,18 @@ export const CONTINUATION_PROFILES: Record<
|
||||
icons: { room: 'lucide:square', single: 'lucide:minus' },
|
||||
},
|
||||
fence: {
|
||||
options: ['continuous', 'single'],
|
||||
options: ['single', 'continuous', 'curved'],
|
||||
default: 'continuous',
|
||||
labels: { continuous: 'Continuous', single: 'Single fence' },
|
||||
icons: { continuous: 'lucide:waypoints', single: 'lucide:minus' },
|
||||
labels: {
|
||||
continuous: 'Continuous',
|
||||
single: 'Single fence',
|
||||
curved: 'Curved fence',
|
||||
},
|
||||
icons: {
|
||||
continuous: 'lucide:waypoints',
|
||||
single: 'lucide:minus',
|
||||
curved: 'lucide:spline',
|
||||
},
|
||||
},
|
||||
point: {
|
||||
options: ['once', 'repeat'],
|
||||
|
||||
@@ -71,6 +71,19 @@ describe('canDirectMoveNode', () => {
|
||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects MEP kinds that own move through bespoke selection affordances', () => {
|
||||
for (const kind of [
|
||||
'duct-segment',
|
||||
'duct-fitting',
|
||||
'pipe-segment',
|
||||
'pipe-fitting',
|
||||
'lineset',
|
||||
'liquid-line',
|
||||
]) {
|
||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('accepts kinds with a bespoke move tool', () => {
|
||||
const kind = 'direct-move-bespoke-tool-test'
|
||||
registerTestDefinition(kind, {
|
||||
|
||||
@@ -34,7 +34,22 @@ export function canDirectRotateNode(node: AnyNode): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
const BESPOKE_SELECTION_MOVE_KINDS = new Set([
|
||||
'duct-segment',
|
||||
'duct-fitting',
|
||||
'pipe-segment',
|
||||
'pipe-fitting',
|
||||
'lineset',
|
||||
'liquid-line',
|
||||
])
|
||||
|
||||
export function canDirectMoveNode(node: AnyNode): boolean {
|
||||
// These MEP kinds own move through bespoke selection rigs (latch cubes,
|
||||
// directional arrows, grid-driven previews). Sending body drags/clicks
|
||||
// through the generic direct-move handoff conflicts with that path and can
|
||||
// leave the editor appearing frozen while their mover waits for the wrong
|
||||
// gesture stream.
|
||||
if (BESPOKE_SELECTION_MOVE_KINDS.has(node.type)) return false
|
||||
// 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
|
||||
// mounts in 3D — distinct from `isRegistryMovable`, which also accepts
|
||||
// floorplan-only movers (zone) for the 2D plan.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { AnyNode, EditorApi } from '@pascal-app/core'
|
||||
import useEditor from '../store/use-editor'
|
||||
import useInteractionScope from '../store/use-interaction-scope'
|
||||
import { endpointReshapeScope } from './interaction/scope'
|
||||
import {
|
||||
controlPointReshapeScope,
|
||||
endpointReshapeScope,
|
||||
tangentReshapeScope,
|
||||
} from './interaction/scope'
|
||||
|
||||
/**
|
||||
* Concrete {@link EditorApi} backed by `useEditor` + the interaction scope.
|
||||
@@ -36,5 +40,11 @@ export function createEditorApi(): EditorApi {
|
||||
// endpoint-draggable kind needs no entry here.
|
||||
useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint))
|
||||
},
|
||||
engageControlPointMove(node: AnyNode, index: number) {
|
||||
useInteractionScope.getState().begin(controlPointReshapeScope(node.id, index))
|
||||
},
|
||||
engageTangentMove(node: AnyNode, index: number, side: 'in' | 'out') {
|
||||
useInteractionScope.getState().begin(tangentReshapeScope(node.id, index, side))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import {
|
||||
type AlignmentAnchor,
|
||||
type AlignmentGuide,
|
||||
collectAlignmentAnchors,
|
||||
resolveAlignment,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import { resolveAlignmentForFloorplanView } from '../world-grid-snap'
|
||||
|
||||
/**
|
||||
* Fixed Figma-style alignment threshold (meters) for floor-plan placement /
|
||||
@@ -49,7 +49,7 @@ export function applyFloorplanAlignment(
|
||||
return { point: [point[0], point[1]], snapped: false, guides: [] }
|
||||
}
|
||||
|
||||
const result = resolveAlignment({
|
||||
const result = resolveAlignmentForFloorplanView({
|
||||
moving: movingAnchors,
|
||||
candidates,
|
||||
threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M,
|
||||
|
||||
@@ -570,7 +570,7 @@ function bakeSwingDoorClip(
|
||||
|
||||
doorObject.traverse((object) => {
|
||||
const marker = object.userData.pascalSwingLeaf as SwingLeafMarker | undefined
|
||||
if (!marker || marker.axis !== 'y') return
|
||||
if (marker?.axis !== 'y') return
|
||||
|
||||
object.rotation.y = 0
|
||||
const closed = object.quaternion.clone()
|
||||
|
||||
@@ -18,7 +18,7 @@ export type InteractionView = '2d' | '3d'
|
||||
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
|
||||
// (rather than four sibling scopes) keeps the union small while still making
|
||||
// "curving and hole-editing at once" unrepresentable.
|
||||
export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary'
|
||||
export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' | 'control-point' | 'tangent'
|
||||
|
||||
export type InteractionScope =
|
||||
| { kind: 'idle' }
|
||||
@@ -49,6 +49,8 @@ export type InteractionScope =
|
||||
reshape: ReshapeKind
|
||||
holeIndex?: number
|
||||
endpoint?: 'start' | 'end'
|
||||
index?: number
|
||||
side?: 'in' | 'out'
|
||||
}
|
||||
// Marquee selection drag.
|
||||
| { kind: 'box-select' }
|
||||
@@ -151,6 +153,27 @@ export function endpointReshapeInfo(
|
||||
: null
|
||||
}
|
||||
|
||||
export function controlPointReshapeInfo(
|
||||
scope: InteractionScope,
|
||||
): { nodeId: string; index: number } | null {
|
||||
return scope.kind === 'reshaping' &&
|
||||
scope.reshape === 'control-point' &&
|
||||
scope.index !== undefined
|
||||
? { nodeId: scope.nodeId, index: scope.index }
|
||||
: null
|
||||
}
|
||||
|
||||
export function tangentReshapeInfo(
|
||||
scope: InteractionScope,
|
||||
): { nodeId: string; index: number; side: 'in' | 'out' } | null {
|
||||
return scope.kind === 'reshaping' &&
|
||||
scope.reshape === 'tangent' &&
|
||||
scope.index !== undefined &&
|
||||
scope.side !== undefined
|
||||
? { nodeId: scope.nodeId, index: scope.index, side: scope.side }
|
||||
: null
|
||||
}
|
||||
|
||||
// The id of the node being reshaped (any reshape kind), for the scene lookup
|
||||
// that recovers the full node payload a few consumers still need.
|
||||
export function reshapingNodeId(scope: InteractionScope): string | null {
|
||||
@@ -169,6 +192,18 @@ export function endpointReshapeScope(
|
||||
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
|
||||
}
|
||||
|
||||
export function controlPointReshapeScope(nodeId: string, index: number): ActiveInteractionScope {
|
||||
return { kind: 'reshaping', nodeId, reshape: 'control-point', index }
|
||||
}
|
||||
|
||||
export function tangentReshapeScope(
|
||||
nodeId: string,
|
||||
index: number,
|
||||
side: 'in' | 'out',
|
||||
): ActiveInteractionScope {
|
||||
return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side }
|
||||
}
|
||||
|
||||
// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
|
||||
// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
|
||||
export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope {
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
function getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null {
|
||||
const { nodes } = useScene.getState()
|
||||
const level = nodes[levelId]
|
||||
if (!level || level.type !== 'level' || !level.parentId) return null
|
||||
if (level?.type !== 'level' || !level.parentId) return null
|
||||
|
||||
const building = nodes[level.parentId as AnyNodeId]
|
||||
if (!building || building.type !== 'building') return null
|
||||
if (building?.type !== 'building') return null
|
||||
|
||||
const siblingLevelIds = (building as BuildingNode).children.filter(
|
||||
(childId): childId is LevelNode['id'] => nodes[childId as AnyNodeId]?.type === 'level',
|
||||
|
||||
@@ -80,7 +80,7 @@ export function duplicateRoofSubtree(
|
||||
const scene = useScene.getState()
|
||||
const sourceRoof = scene.nodes[sourceRoofId]
|
||||
|
||||
if (!sourceRoof || sourceRoof.type !== 'roof') {
|
||||
if (sourceRoof?.type !== 'roof') {
|
||||
throw new Error(`Node "${sourceRoofId}" is not a roof`)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function duplicateRoofSubtree(
|
||||
const segmentClones: RoofSegmentNode[] = []
|
||||
for (const childId of sourceRoof.children ?? []) {
|
||||
const childNode = scene.nodes[childId as AnyNodeId]
|
||||
if (!childNode || childNode.type !== 'roof-segment') {
|
||||
if (childNode?.type !== 'roof-segment') {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function duplicateRoofSubtree(
|
||||
|
||||
const nextScene = useScene.getState()
|
||||
const createdRoof = nextScene.nodes[roofClone.id as AnyNodeId]
|
||||
if (!createdRoof || createdRoof.type !== 'roof') {
|
||||
if (createdRoof?.type !== 'roof') {
|
||||
throw new Error(`Duplicated roof "${roofClone.id}" was not created`)
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function duplicateRoofSubtree(
|
||||
|
||||
const invalidSegment = segmentIds.find((segmentId) => {
|
||||
const segment = nextScene.nodes[segmentId as AnyNodeId]
|
||||
return !segment || segment.type !== 'roof-segment' || segment.parentId !== createdRoof.id
|
||||
return segment?.type !== 'roof-segment' || segment.parentId !== createdRoof.id
|
||||
})
|
||||
if (invalidSegment) {
|
||||
throw new Error(
|
||||
@@ -179,7 +179,7 @@ export function clearRoofDuplicateMetadata(
|
||||
) {
|
||||
const scene = useScene.getState()
|
||||
const roofNode = scene.nodes[roofId]
|
||||
if (!roofNode || roofNode.type !== 'roof') {
|
||||
if (roofNode?.type !== 'roof') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ export function clearRoofDuplicateMetadata(
|
||||
|
||||
for (const childId of roofNode.children ?? []) {
|
||||
const childNode = scene.nodes[childId as AnyNodeId]
|
||||
if (!childNode || childNode.type !== 'roof-segment') {
|
||||
if (childNode?.type !== 'roof-segment') {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
export const HOVERED_ROOF_SEGMENT_OUTLINE_PROXY_NAME = '__roof-hover-outline-proxy__'
|
||||
|
||||
function hoveredRoofSegmentOutlineProxyName(segmentId: string) {
|
||||
return `${HOVERED_ROOF_SEGMENT_OUTLINE_PROXY_NAME}:${segmentId}`
|
||||
}
|
||||
|
||||
export function getHoveredRoofSegmentOutlineProxy(segmentId: string): THREE.Object3D | null {
|
||||
const segment = useScene.getState().nodes[segmentId as AnyNodeId]
|
||||
if (!(segment?.type === 'roof-segment' && segment.parentId)) return null
|
||||
return (
|
||||
sceneRegistry.nodes
|
||||
.get(segment.parentId)
|
||||
?.getObjectByName(hoveredRoofSegmentOutlineProxyName(segmentId)) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function getHoveredRoofSegmentOutlineProxyName(segmentId: string): string {
|
||||
return hoveredRoofSegmentOutlineProxyName(segmentId)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ function getNextLevelId(level: LevelNode, nodes: Record<AnyNodeId, AnyNode>) {
|
||||
if (!parentId) return null
|
||||
|
||||
const building = nodes[parentId]
|
||||
if (!building || building.type !== 'building') return null
|
||||
if (building?.type !== 'building') return null
|
||||
|
||||
const siblingLevels = building.children
|
||||
.map((childId) => nodes[childId as AnyNodeId])
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
resolveNodeSelectionTarget,
|
||||
resolveSelectedIdsForNodeClick,
|
||||
selectionModifiersFromEvent,
|
||||
shouldPreserveSelectedRoofHostTarget,
|
||||
} from './selection-routing'
|
||||
|
||||
describe('resolveSelectedIdsForNodeClick', () => {
|
||||
@@ -77,3 +78,41 @@ describe('resolveNodeSelectionTarget', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreserveSelectedRoofHostTarget', () => {
|
||||
test('keeps the roof host target while that roof is the sole armed selection', () => {
|
||||
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
|
||||
|
||||
expect(
|
||||
shouldPreserveSelectedRoofHostTarget({
|
||||
node,
|
||||
selectedIds: ['roof_1'],
|
||||
armedRoofId: 'roof_1',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('falls back to segment targeting when the roof host is not armed', () => {
|
||||
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
|
||||
|
||||
expect(
|
||||
shouldPreserveSelectedRoofHostTarget({
|
||||
node,
|
||||
selectedIds: ['roof_1'],
|
||||
armedRoofId: null,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('falls back to segment targeting when the roof is no longer the sole selection', () => {
|
||||
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
|
||||
|
||||
expect(
|
||||
shouldPreserveSelectedRoofHostTarget({
|
||||
node,
|
||||
selectedIds: ['roof_1', 'wall_1'],
|
||||
armedRoofId: 'roof_1',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,6 +66,23 @@ export function resolveSelectedIdsForNodeClick({
|
||||
return [nodeId]
|
||||
}
|
||||
|
||||
export function shouldPreserveSelectedRoofHostTarget({
|
||||
node,
|
||||
selectedIds,
|
||||
armedRoofId,
|
||||
}: {
|
||||
node: AnyNode
|
||||
selectedIds: readonly string[]
|
||||
armedRoofId: string | null
|
||||
}): boolean {
|
||||
return (
|
||||
node.type === 'roof' &&
|
||||
armedRoofId === node.id &&
|
||||
selectedIds.length === 1 &&
|
||||
selectedIds[0] === node.id
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveNodeSelectionTarget(node: AnyNode): NodeSelectionTarget | null {
|
||||
if (node.type === 'building') {
|
||||
return { phase: 'site' }
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
||||
// Roof / stair / elevator are placed as footprints, not directional draws →
|
||||
// declared `snapDraftDirectional: false`, so their draft context drops the
|
||||
// angle-lock mode. Directional structural kinds (no flag) stay `wall`.
|
||||
const draftDirectionalOf = (t: string) => (t === 'roof' ? false : true)
|
||||
const draftDirectionalOf = (t: string) => t !== 'roof'
|
||||
const draftCtx = (tool: string) =>
|
||||
snapContextOf({ scope: { kind: 'idle' }, mode: 'build', tool, profileOf, draftDirectionalOf })
|
||||
expect(draftCtx('roof')).toBe('polygon')
|
||||
|
||||
@@ -37,7 +37,7 @@ function stripDuplicateFlags(metadata: unknown) {
|
||||
|
||||
function moveStairWhenRegistered(stairId: StairNode['id'], attempt = 0) {
|
||||
const latestStair = useScene.getState().nodes[stairId as AnyNodeId]
|
||||
if (!latestStair || latestStair.type !== 'stair') {
|
||||
if (latestStair?.type !== 'stair') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function duplicateStairSubtree(
|
||||
const scene = useScene.getState()
|
||||
const sourceStair = scene.nodes[sourceStairId]
|
||||
|
||||
if (!sourceStair || sourceStair.type !== 'stair') {
|
||||
if (sourceStair?.type !== 'stair') {
|
||||
throw new Error(`Node "${sourceStairId}" is not a stair`)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function duplicateStairSubtree(
|
||||
const segmentClones: StairSegmentNode[] = []
|
||||
for (const childId of sourceStair.children ?? []) {
|
||||
const childNode = scene.nodes[childId as AnyNodeId]
|
||||
if (!childNode || childNode.type !== 'stair-segment') {
|
||||
if (childNode?.type !== 'stair-segment') {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function duplicateStairSubtree(
|
||||
])
|
||||
|
||||
const createdStair = useScene.getState().nodes[stairClone.id as AnyNodeId]
|
||||
if (!createdStair || createdStair.type !== 'stair') {
|
||||
if (createdStair?.type !== 'stair') {
|
||||
throw new Error(`Duplicated stair "${stairClone.id}" was not created`)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
isCurvedWall,
|
||||
resolveAlignment,
|
||||
resolveLevelId,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
import useAlignmentGuides from '../store/use-alignment-guides'
|
||||
import { isMagneticSnapActive } from '../store/use-editor'
|
||||
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
|
||||
import { resolveAlignmentForFloorplanView } from './world-grid-snap'
|
||||
|
||||
const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
|
||||
export const SURFACE_ALIGNMENT_THRESHOLD_M = 0.08
|
||||
@@ -172,12 +172,6 @@ export function clearSurfacePlanSnapFeedback() {
|
||||
}
|
||||
|
||||
export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult {
|
||||
if (input.shiftKey) {
|
||||
useWallSnapIndicator.getState().clear()
|
||||
useAlignmentGuides.getState().clear()
|
||||
return { point: input.rawPoint, wallSnap: null, guides: [], wallIds: [] }
|
||||
}
|
||||
|
||||
const nodes = input.nodes ?? useScene.getState().nodes
|
||||
const walls = getLevelWalls(nodes, input.levelId, input.walls)
|
||||
const fallbackPoint = input.fallbackPoint
|
||||
@@ -213,7 +207,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
|
||||
// only when magnetic snap is on — `grid`/`angles`/`off` keep the grid/raw
|
||||
// `fallbackPoint` instead of being pulled onto an alignment axis.
|
||||
const basePoint = fallbackPoint ?? wallSnap.point
|
||||
if (input.align === false || input.altKey || !magnetic) {
|
||||
if (input.align === false || !magnetic) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
|
||||
}
|
||||
@@ -228,7 +222,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
|
||||
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
|
||||
}
|
||||
|
||||
const alignment = resolveAlignment({
|
||||
const alignment = resolveAlignmentForFloorplanView({
|
||||
moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }],
|
||||
candidates,
|
||||
threshold: input.threshold ?? SURFACE_ALIGNMENT_THRESHOLD_M,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AlignmentGuide,
|
||||
type AnyNodeId,
|
||||
type BuildingPose,
|
||||
type ResolveAlignmentInBuildingResult,
|
||||
@@ -54,7 +55,7 @@ export function getActiveBuildingPose(): BuildingPose | null {
|
||||
}
|
||||
if (!buildingId) buildingId = sel.buildingId ?? null
|
||||
const building = buildingId ? nodes[buildingId] : null
|
||||
if (!building || building.type !== 'building') return null
|
||||
if (building?.type !== 'building') return null
|
||||
const live = useLiveTransforms.getState().transforms.get(buildingId as string)
|
||||
return {
|
||||
position: live?.position ?? building.position,
|
||||
@@ -80,6 +81,37 @@ export function resolveAlignmentForActiveBuilding(args: {
|
||||
return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() })
|
||||
}
|
||||
|
||||
function worldXZToPoseLocal(x: number, z: number, pose: BuildingPose | null): [number, number] {
|
||||
if (!pose) return [x, z]
|
||||
const cos = Math.cos(pose.rotationY)
|
||||
const sin = Math.sin(pose.rotationY)
|
||||
const dx = x - pose.position[0]
|
||||
const dz = z - pose.position[2]
|
||||
return [dx * cos - dz * sin, dx * sin + dz * cos]
|
||||
}
|
||||
|
||||
/**
|
||||
* Project WORLD-frame alignment guides into the active building's LOCAL frame.
|
||||
*
|
||||
* The 3D alignment layer is still mounted inside the building-local tool group,
|
||||
* so tools that resolve alignment on the world axes (item placement, slab move)
|
||||
* need their guides converted before publishing to `useAlignmentGuides`.
|
||||
*/
|
||||
export function projectAlignmentGuidesWorldToActiveBuildingLocal(
|
||||
guides: readonly AlignmentGuide[],
|
||||
): AlignmentGuide[] {
|
||||
const pose = getActiveBuildingPose()
|
||||
return guides.map((guide) => {
|
||||
const [fromX, fromZ] = worldXZToPoseLocal(guide.from.x, guide.from.z, pose)
|
||||
const [toX, toZ] = worldXZToPoseLocal(guide.to.x, guide.to.z, pose)
|
||||
return {
|
||||
...guide,
|
||||
from: { x: fromX, z: fromZ },
|
||||
to: { x: toX, z: toZ },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline rotation the floor-plan view applies on top of the building
|
||||
* rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 90` in floorplan-panel.tsx —
|
||||
@@ -159,7 +191,7 @@ export function snapWorldXZForActiveBuilding(
|
||||
): { world: [number, number]; local: [number, number] } {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const building = buildingId ? useScene.getState().nodes[buildingId] : null
|
||||
if (!building || building.type !== 'building') {
|
||||
if (building?.type !== 'building') {
|
||||
if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] }
|
||||
const sx = Math.round(worldX / step) * step
|
||||
const sz = Math.round(worldZ / step) * step
|
||||
@@ -186,7 +218,7 @@ export function snapBuildingLocalToWorldGrid(
|
||||
): [number, number] {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const building = buildingId ? useScene.getState().nodes[buildingId] : null
|
||||
if (!building || building.type !== 'building') {
|
||||
if (building?.type !== 'building') {
|
||||
if (step <= 0) return [local[0], local[1]]
|
||||
return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step]
|
||||
}
|
||||
|
||||
@@ -239,6 +239,8 @@ type EditorState = {
|
||||
*/
|
||||
placementDragMode: boolean
|
||||
setPlacementDragMode: (dragMode: boolean) => void
|
||||
roofHostDragArmedId: AnyNodeId | null
|
||||
setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void
|
||||
setMovingNode: (
|
||||
node:
|
||||
| ItemNode
|
||||
@@ -870,6 +872,8 @@ const useEditor = create<EditorState>()(
|
||||
setSelectedItem: (item) => set({ selectedItem: item }),
|
||||
placementDragMode: false,
|
||||
setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }),
|
||||
roofHostDragArmedId: null,
|
||||
setRoofHostDragArmedId: (nodeId) => set({ roofHostDragArmedId: nodeId }),
|
||||
// The node being placed/moved now lives inside the interaction scope
|
||||
// (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter
|
||||
// remains the single entry point: it drives the scope and still touches
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Ephemeral store: how many points the in-progress curved-fence draft has
|
||||
// placed. Written by the 3D spline draft tool (`@pascal-app/nodes`
|
||||
// fence/tool.tsx) and read by the contextual helper so the "finish curve" hint
|
||||
// only surfaces once the user has actually started drawing. Reset on commit,
|
||||
// cancel, and unmount — never persisted, never in undo history.
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
type FenceCurveDraftState = {
|
||||
pointCount: number
|
||||
setPointCount(count: number): void
|
||||
reset(): void
|
||||
}
|
||||
|
||||
const useFenceCurveDraft = create<FenceCurveDraftState>((set) => ({
|
||||
pointCount: 0,
|
||||
setPointCount: (count) => set({ pointCount: count }),
|
||||
reset: () => set({ pointCount: 0 }),
|
||||
}))
|
||||
|
||||
export default useFenceCurveDraft
|
||||
@@ -6,6 +6,7 @@ import { create } from 'zustand'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
type ActiveInteractionScope,
|
||||
controlPointReshapeInfo,
|
||||
editingHoleInfo,
|
||||
endpointReshapeInfo,
|
||||
handleDragInfo,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
isCurveReshape,
|
||||
movingNodeOf,
|
||||
reshapingNodeId,
|
||||
tangentReshapeInfo,
|
||||
} from '../lib/interaction/scope'
|
||||
|
||||
// The authoritative interaction state machine. A single owner holds exactly one
|
||||
@@ -87,6 +89,12 @@ export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCur
|
||||
export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null =>
|
||||
useInteractionScope(useShallow((s) => endpointReshapeInfo(s.scope)))
|
||||
|
||||
export const useControlPointReshape = (): { nodeId: string; index: number } | null =>
|
||||
useInteractionScope(useShallow((s) => controlPointReshapeInfo(s.scope)))
|
||||
|
||||
export const useTangentReshape = (): { nodeId: string; index: number; side: 'in' | 'out' } | null =>
|
||||
useInteractionScope(useShallow((s) => tangentReshapeInfo(s.scope)))
|
||||
|
||||
// The node currently being reshaped (curve / endpoint / hole), looked up live
|
||||
// from the scene by the scope's `nodeId`. During a reshape the scene node holds
|
||||
// the same data the legacy `curvingWall` / `movingWallEndpoint.wall` carried, so
|
||||
|
||||
@@ -1111,7 +1111,7 @@ export async function convertIfcToPascal(
|
||||
const wallNodeId = expressIdToNodeId.get(wallExpressID)
|
||||
if (!wallNodeId) continue
|
||||
const wallNode = nodes[wallNodeId] as WallNode
|
||||
if (!wallNode || wallNode.type !== 'wall') continue
|
||||
if (wallNode?.type !== 'wall') continue
|
||||
|
||||
const wallDx = wallNode.end[0] - wallNode.start[0]
|
||||
const wallDy = wallNode.end[1] - wallNode.start[1]
|
||||
@@ -1288,7 +1288,7 @@ export async function convertIfcToPascal(
|
||||
const wallInfos: WallInfo[] = []
|
||||
for (const [wallExpressId, wallNodeId] of expressIdToNodeId) {
|
||||
const node = nodes[wallNodeId]
|
||||
if (!node || node.type !== 'wall') continue
|
||||
if (node?.type !== 'wall') continue
|
||||
const w = node as WallNode
|
||||
const length = Math.hypot(w.end[0] - w.start[0], w.end[1] - w.start[1])
|
||||
if (length < 1e-6) continue
|
||||
|
||||
@@ -41,7 +41,7 @@ function buildPayload(
|
||||
levelId: string,
|
||||
): ConstraintsPayload | ConstraintsError {
|
||||
const level = bridge.getNode(levelId as never)
|
||||
if (!level || level.type !== 'level') {
|
||||
if (level?.type !== 'level') {
|
||||
return {
|
||||
error: 'level_not_found',
|
||||
levelId,
|
||||
|
||||
@@ -121,6 +121,48 @@ describe('construction tools', () => {
|
||||
expect(bridge.validateScene().valid).toBe(true)
|
||||
})
|
||||
|
||||
test('create_stair_between_levels defaults opening offset to zero', async () => {
|
||||
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
|
||||
const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||
const upper = LevelNode.parse({ name: 'Second Floor', level: 1, metadata: { height: 2.8 } })
|
||||
bridge.createNode(upper, building.id)
|
||||
|
||||
for (const level of [ground, upper]) {
|
||||
const result = await client.callTool({
|
||||
name: 'create_story_shell',
|
||||
arguments: {
|
||||
levelId: level.id,
|
||||
footprint: [
|
||||
[-4, -3],
|
||||
[4, -3],
|
||||
[4, 3],
|
||||
[-4, 3],
|
||||
],
|
||||
wallHeight: 2.8,
|
||||
},
|
||||
})
|
||||
expect(result.isError).toBeFalsy()
|
||||
}
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'create_stair_between_levels',
|
||||
arguments: {
|
||||
fromLevelId: ground.id,
|
||||
toLevelId: upper.id,
|
||||
position: [0, 0, -1],
|
||||
width: 1,
|
||||
runLength: 3,
|
||||
totalRise: 2.8,
|
||||
},
|
||||
})
|
||||
expect(result.isError).toBeFalsy()
|
||||
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
|
||||
const stair = bridge.getNode(parsed.stairId)
|
||||
|
||||
expect(stair?.type).toBe('stair')
|
||||
if (stair?.type === 'stair') expect(stair.openingOffset).toBe(0)
|
||||
})
|
||||
|
||||
test('verify_scene flags suspicious multi-story wall heights', async () => {
|
||||
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
|
||||
const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
|
||||
|
||||
@@ -85,7 +85,7 @@ export const createStairBetweenLevelsInput = {
|
||||
createSourceCeilingOpening: z.boolean().default(true),
|
||||
openingWidth: z.number().positive().optional(),
|
||||
openingLength: z.number().positive().optional(),
|
||||
openingOffset: z.number().min(0).default(0.15),
|
||||
openingOffset: z.number().min(0).default(0),
|
||||
openingCenter: Vec2Schema.optional(),
|
||||
openingRotation: z.number().optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
|
||||
@@ -100,7 +100,7 @@ export function registerFindNodes(server: McpServer, bridge: SceneOperations): v
|
||||
// Zone-polygon filter: point-in-polygon on a representative 2D point.
|
||||
if (zoneId) {
|
||||
const zone = bridge.getNode(zoneId as AnyNodeId)
|
||||
if (!zone || zone.type !== 'zone') {
|
||||
if (zone?.type !== 'zone') {
|
||||
// Unknown zoneId → return empty list rather than throw; matches
|
||||
// typical "filter" semantics.
|
||||
results = []
|
||||
|
||||
@@ -396,7 +396,7 @@ function parentListsChild(parent: AnyNode, childId: string): boolean {
|
||||
|
||||
function levelSummary(bridge: SceneOperations, levelId: AnyNodeId) {
|
||||
const level = bridge.getNode(levelId)
|
||||
if (!level || level.type !== 'level') {
|
||||
if (level?.type !== 'level') {
|
||||
throw new Error(`Level not found: ${levelId}`)
|
||||
}
|
||||
const nodes = nodesOnLevel(bridge, levelId)
|
||||
@@ -680,7 +680,7 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations):
|
||||
for (const node of Object.values(bridge.getNodes())) {
|
||||
if (node.type === 'door' || node.type === 'window') {
|
||||
const parent = node.parentId ? bridge.getNode(node.parentId as AnyNodeId) : null
|
||||
if (!parent || parent.type !== 'wall') {
|
||||
if (parent?.type !== 'wall') {
|
||||
issues.push(`${node.type} ${node.id} is not parented to a wall`)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user