diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 0ea0b3553..bb4765429 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -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([ ...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 diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/biome.jsonc b/biome.jsonc index 9b347988e..c72578720 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -81,9 +81,6 @@ "noNoninteractiveElementInteractions": "off", "useButtonType": "off" }, - "nursery": { - "noShadow": "off" - }, "security": { "noDangerouslySetInnerHtml": "info" } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc35f98f5..81a90f15c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index a248b54d9..0b0bb5f6f 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -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 = { * 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 diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index e9d270dcc..b0f0d4cd3 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -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, diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index 641fd3d00..e8e768742 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -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 diff --git a/packages/core/src/schema/nodes/ridge-vent-defaults.test.ts b/packages/core/src/schema/nodes/ridge-vent-defaults.test.ts new file mode 100644 index 000000000..024992de7 --- /dev/null +++ b/packages/core/src/schema/nodes/ridge-vent-defaults.test.ts @@ -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) + } + }) +}) diff --git a/packages/core/src/schema/nodes/ridge-vent.ts b/packages/core/src/schema/nodes/ridge-vent.ts index 9a7dce48f..31433d88b 100644 --- a/packages/core/src/schema/nodes/ridge-vent.ts +++ b/packages/core/src/schema/nodes/ridge-vent.ts @@ -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 + +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).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 { + if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) { + return metadata as Record + } + return {} +} + +export function hasAutoRidgeVentMetadata( + segment: Pick, +): segment is Pick & { + metadata: Record & { autoRidgeVent: boolean } +} { + return typeof metadataRecord(segment.metadata)[AUTO_RIDGE_VENT_METADATA_KEY] === 'boolean' +} + +export function isAutoRidgeVentEnabled( + segment: Pick, + nodes?: Record, +): 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), + ) +} diff --git a/packages/core/src/schema/nodes/roof-segment-shape.test.ts b/packages/core/src/schema/nodes/roof-segment-shape.test.ts new file mode 100644 index 000000000..64ff58a17 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-shape.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-shape.ts b/packages/core/src/schema/nodes/roof-segment-shape.ts new file mode 100644 index 000000000..ac1d5787d --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-shape.ts @@ -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 +} diff --git a/packages/core/src/schema/nodes/roof-segment-surface.test.ts b/packages/core/src/schema/nodes/roof-segment-surface.test.ts new file mode 100644 index 000000000..17ba53af0 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-surface.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-trim.test.ts b/packages/core/src/schema/nodes/roof-segment-trim.test.ts new file mode 100644 index 000000000..d35de190c --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-trim.test.ts @@ -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[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) + }) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.test.ts b/packages/core/src/schema/nodes/roof-segment-walls.test.ts index 7e4d74784..61c087f55 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.test.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.test.ts @@ -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) + }) }) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts index 7d783592d..d6fa9c5bc 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -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) } diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 201f5ed05..2f13fdc2a 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -8,6 +8,50 @@ export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'man export type RoofType = z.infer +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 + // 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 +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 & { trim?: Partial }, +): 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 +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 & + Partial< + Pick + >, +) { + 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 & + Partial>, +): 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 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 & @@ -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 diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 94997dba1..bc7b524a4 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -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([ + '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 return { ...currentNode, ...(sanitized.value as Partial) } as AnyNode } +function shouldRefreshDefaultRidgeVents(data: Partial) { + return Object.keys(data).some((key) => DEFAULT_RIDGE_VENT_REFRESH_FIELDS.has(key)) +} + +function refreshDefaultRidgeVentsForSegment( + nextNodes: Record, + 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 = 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() + const extraNodesToUpdate = new Set() 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) diff --git a/packages/core/src/store/actions/ridge-vent-update.test.ts b/packages/core/src/store/actions/ridge-vent-update.test.ts new file mode 100644 index 000000000..19bfac4b5 --- /dev/null +++ b/packages/core/src/store/actions/ridge-vent-update.test.ts @@ -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, + [roof.id as AnyNodeId], + ) + + const oldDefaultIds = defaults.map((vent) => vent.id) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 12 } as Partial) + + 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, + [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, + ) + + 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, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + roofType: 'gable', + } as Partial, + ) + + 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, + [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, + ) + + 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, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + pitch: 52, + } as Partial, + ) + + 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, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + pitch: 52, + } as Partial, + ) + + 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, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + roofType: 'gable', + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + + expect(nextSegment?.children ?? []).toHaveLength(0) + }) +}) diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index 5239434cf..736ffe1dd 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -83,7 +83,7 @@ export function resolveElevatorServiceLevels( export function getElevatorLevelHeight(levelId: string, nodes: Record): 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 diff --git a/packages/core/src/systems/fence/fence-centerline.ts b/packages/core/src/systems/fence/fence-centerline.ts new file mode 100644 index 000000000..0cfc402cf --- /dev/null +++ b/packages/core/src/systems/fence/fence-centerline.ts @@ -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) +} diff --git a/packages/core/src/systems/fence/fence-spline.test.ts b/packages/core/src/systems/fence/fence-spline.test.ts new file mode 100644 index 000000000..9f4e50097 --- /dev/null +++ b/packages/core/src/systems/fence/fence-spline.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/systems/fence/fence-spline.ts b/packages/core/src/systems/fence/fence-spline.ts new file mode 100644 index 000000000..4d55cf744 --- /dev/null +++ b/packages/core/src/systems/fence/fence-spline.ts @@ -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 +type TangentList = ReadonlyArray | 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): 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, + 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, +): 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, + 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, + 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, + 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 +} diff --git a/packages/core/src/systems/stair/stair-footprint.ts b/packages/core/src/systems/stair/stair-footprint.ts index 9a6e88ad4..cf41533da 100644 --- a/packages/core/src/systems/stair/stair-footprint.ts +++ b/packages/core/src/systems/stair/stair-footprint.ts @@ -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) } } diff --git a/packages/core/src/systems/stair/stair-opening-sync.test.ts b/packages/core/src/systems/stair/stair-opening-sync.test.ts index 2ba5169cc..9068c029f 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.test.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -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 + + 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 }]) + }) }) diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index def28ce64..36b1c2ffa 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -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, @@ -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') { diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index a69e91b52..30aa6293a 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -155,7 +155,7 @@ export function cloneLevelSubtree( levelId: AnyNodeId, ): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map } { const levelNode = nodes[levelId] - if (!levelNode || levelNode.type !== 'level') { + if (levelNode?.type !== 'level') { throw new Error(`Node "${levelId}" is not a level`) } diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 74f599a01..027a4adc1 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -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' @@ -436,11 +436,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 ? (() => { @@ -555,7 +557,7 @@ export function FloorplanRegistryMoveOverlay() { // store, which the 2D FloorplanAlignmentGuideLayer renders // inside the rotated scene . 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, diff --git a/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx index 85fd3bb92..0a943e2f1 100644 --- a/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx @@ -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 + return } if (kind === 'midpoint') { const t = m * 1.3 diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 3d120ce74..feb67c05f 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -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) diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx index 4333b6225..fb6b65062 100644 --- a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx +++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx @@ -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 }) { { 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 diff --git a/packages/editor/src/components/editor/fence-tangent-lines-3d.tsx b/packages/editor/src/components/editor/fence-tangent-lines-3d.tsx new file mode 100644 index 000000000..44c9edf1e --- /dev/null +++ b/packages/editor/src/components/editor/fence-tangent-lines-3d.tsx @@ -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(() => { + 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( + + + , + selectedObject, + ) +} diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 6c93d7b5a..b0ab37951 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -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 } diff --git a/packages/editor/src/components/editor/floating-building-action-menu.tsx b/packages/editor/src/components/editor/floating-building-action-menu.tsx index 3f935b591..f5e9cc8e9 100644 --- a/packages/editor/src/components/editor/floating-building-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-building-action-menu.tsx @@ -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 }) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index e562d403f..39d5c0f0d 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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 }, - [], + [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) => { @@ -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({ )} - {(!levelNode || levelNode.type !== 'level') && !hasAmbientBuildingLevel ? ( + {levelNode?.type !== 'level' && !hasAmbientBuildingLevel ? (
Switch to a building level to view and edit the floorplan.
diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index 43cb36070..08b98277e 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -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(null) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 508094e37..55fb152b7 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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 && } {!noEditing && } {!noEditing && } + {!noEditing && } {!noEditing && } {!noEditing && } {!isFirstPersonMode && } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index f4d5cbe8b..c5cc54662 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -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) => 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" /> - + diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 728c214e1..d23204803 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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, -): 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) } } diff --git a/packages/editor/src/components/editor/use-floorplan-scene-data.ts b/packages/editor/src/components/editor/use-floorplan-scene-data.ts index 6f8bd7e19..912f6c425 100644 --- a/packages/editor/src/components/editor/use-floorplan-scene-data.ts +++ b/packages/editor/src/components/editor/use-floorplan-scene-data.ts @@ -36,7 +36,7 @@ function useLevelChildren( } 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[] } diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index 9d3c2ed0b..e3fa0855b 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -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() { @@ -235,7 +244,7 @@ function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number } +type DiagonalTrimAxisKey = DiagonalTrimAxisSide + +const TRIM_PLANE_COLOR = '#93c5fd' +const TRIM_PLANE_OPACITY = 0.18 +const TRIM_PLANE_HOVER_OPACITY = 0.32 +const TRIM_RAIL_COLOR = '#2563eb' +const TRIM_RAIL_HOVER_COLOR = '#4f46e5' +const TRIM_CAP_COLOR = TRIM_RAIL_COLOR +const TRIM_CAP_HOVER_COLOR = TRIM_RAIL_HOVER_COLOR +const TRIM_ADD_COLOR = TRIM_RAIL_COLOR +const TRIM_ADD_HOVER_COLOR = TRIM_RAIL_HOVER_COLOR +const TRIM_PLANE_RENDER_ORDER = 1001 +const TRIM_RAIL_RENDER_ORDER = 1003 +const TRIM_HANDLE_BASE_SCALE = 0.65 +const TRIM_RAIL_SURFACE_OFFSET = 0 +const TRIM_RAIL_HIT_HEIGHT = 0.18 +const TRIM_RAIL_HIT_DEPTH = 0.16 +const TRIM_CAP_HIT_SIZE = 0.22 +const TRIM_LIVE_REBUILD_INTERVAL_MS = 80 + +const TRIM_UNIT_PLANE_GEOMETRY = new THREE.PlaneGeometry(1, 1) +const TRIM_UNIT_RAIL_GEOMETRY = new THREE.BoxGeometry(1, 1, 1) +const TRIM_UNIT_RAIL_CAP_GEOMETRY = new THREE.SphereGeometry(0.5, 16, 8) +const TRIM_UNIT_ADD_GEOMETRY = new THREE.OctahedronGeometry(0.5, 0) + +const trimPlaneMaterial = new MeshBasicNodeMaterial({ + color: TRIM_PLANE_COLOR, + depthTest: false, + depthWrite: false, + opacity: TRIM_PLANE_OPACITY, + side: THREE.DoubleSide, + transparent: true, +}) +const trimPlaneHoverMaterial = new MeshBasicNodeMaterial({ + color: TRIM_PLANE_COLOR, + depthTest: false, + depthWrite: false, + opacity: TRIM_PLANE_HOVER_OPACITY, + side: THREE.DoubleSide, + transparent: true, +}) +const trimRailMaterial = new MeshBasicNodeMaterial({ + color: TRIM_RAIL_COLOR, + depthTest: false, + depthWrite: false, +}) +const trimRailHoverMaterial = new MeshBasicNodeMaterial({ + color: TRIM_RAIL_HOVER_COLOR, + depthTest: false, + depthWrite: false, +}) +const trimCapMaterial = new MeshBasicNodeMaterial({ + color: TRIM_CAP_COLOR, + depthTest: false, + depthWrite: false, + opacity: 1, + transparent: false, +}) +const trimCapHoverMaterial = new MeshBasicNodeMaterial({ + color: TRIM_CAP_HOVER_COLOR, + depthTest: false, + depthWrite: false, + opacity: 1, + transparent: false, +}) +const trimAddMaterial = new MeshBasicNodeMaterial({ + color: TRIM_ADD_COLOR, + depthTest: false, + depthWrite: false, +}) +const trimAddHoverMaterial = new MeshBasicNodeMaterial({ + color: TRIM_ADD_HOVER_COLOR, + depthTest: false, + depthWrite: false, +}) +const trimDiagonalPreviewRailMaterial = new MeshBasicNodeMaterial({ + color: TRIM_RAIL_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.42, + transparent: true, +}) + +// ─── Section-cut (cutaway) feedback ────────────────────────────────── +// The cross-section the trim removes: a thin slab at each cut line is +// intersected with the untrimmed roof shell AND every hosted accessory +// (chimney, vents, skylight, dormer, …), so the fill shows real material only +// (wall + deck bands + any accessory the cut passes through) and leaves the +// hollow attic empty. The outline is the edge silhouette of that fill. A +// translucent red fill + darker red outline read together as a SketchUp-style +// section cut of what the trim removes. +// Matches the app's destructive red (`--destructive`, oklch(0.577 0.245 27.325) +// ≈ #dc2626 / red-600 — the delete/destructive button color). Three's +// MeshBasicNodeMaterial color doesn't parse oklch() strings, so use the sRGB hex +// equivalent; the outline is a darker shade of the same hue. +const SECTION_FILL_COLOR = '#dc2626' +const SECTION_OUTLINE_COLOR = '#991b1b' +const SECTION_FILL_RENDER_ORDER = 1000 +const SECTION_OUTLINE_RENDER_ORDER = 1002 +// Build the cut line just inside the kept material so the section plane never +// sits coplanar with the mesh's own cut face. +const SECTION_PLANE_INSET = 0.004 + +// A vertical cut plane defined by a horizontal line through the XZ ground +// plane. `origin` is a point on the line, `dir` is the unit in-plane +// horizontal direction (in XZ), and `normal` is the unit XZ normal. Vertices +// are projected to (u = dir·xz, v = world Y) for slicing and lifted back via +// `origin + dir`. This handles both the axis-aligned sides (left/right cut on +// x, front/back on z) and the angled diagonal/corner cuts. +type SectionPlaneSpec = { + origin: THREE.Vector2 // point on the cut line, (x, z) + dir: THREE.Vector2 // unit in-plane horizontal direction, (x, z) + normal: THREE.Vector2 // unit normal in the XZ plane, (x, z) + // The cut plane is infinite, but the visible cut only spans the footprint + // edge between the two endpoints. We clip slice segments to this u-range + // (u = projection along `dir`) so the silhouette doesn't sprout lines where + // the infinite plane grazes the rest of the roof. + uMin: number + uMax: number + // How far the slab extends past each end of the cut line. A FREE (untrimmed) + // end has eave/overhang material beyond the footprint edge, so we extend to + // capture it; a TRIMMED end has none, so we clamp to 0 — otherwise the slab + // grabs phantom material from the untrimmed shell and the red section pokes + // out past the trim box. `dir` points A→B, so uMin is always endpoint A and + // uMax endpoint B; `extendMin` applies at A, `extendMax` at B. + extendMin: number + extendMax: number +} + +// Build a section plane from two XZ points on the cut line. `inset` shifts the +// plane along its normal toward the supplied "inside" point so the slice sits +// just inside kept material instead of coplanar with the mesh's own cut face. +// `extendA` / `extendB` set the slab overrun past endpoint A / B (0 at trimmed +// ends, a small overhang allowance at free ends). +function makeSectionPlane( + ax: number, + az: number, + bx: number, + bz: number, + inset = 0, + insidePoint?: readonly [number, number], + extendA = 0, + extendB = 0, +): SectionPlaneSpec { + const dir = new THREE.Vector2(bx - ax, bz - az) + if (dir.lengthSq() < 1e-12) dir.set(1, 0) + dir.normalize() + const normal = new THREE.Vector2(dir.y, -dir.x) + const origin = new THREE.Vector2(ax, az) + if (inset !== 0 && insidePoint) { + const toInsideX = insidePoint[0] - ax + const toInsideZ = insidePoint[1] - az + const sign = normal.x * toInsideX + normal.y * toInsideZ >= 0 ? 1 : -1 + origin.x += normal.x * inset * sign + origin.y += normal.y * inset * sign + } + // dir = (B-A).normalized, so dir·B - dir·A = |B-A| > 0 → uB is always uMax. + const uA = dir.x * ax + dir.y * az + const uB = dir.x * bx + dir.y * bz + return { origin, dir, normal, uMin: uA, uMax: uB, extendMin: extendA, extendMax: extendB } +} + +// Lift a 2D plane-frame point (u = projection along dir, v = world Y) back to +// segment-local 3D using the plane's origin and direction. +function liftSectionPoint(plane: SectionPlaneSpec, u: number, v: number): [number, number, number] { + const uOrigin = plane.dir.x * plane.origin.x + plane.dir.y * plane.origin.y + const t = u - uOrigin + return [plane.origin.x + plane.dir.x * t, v, plane.origin.y + plane.dir.y * t] +} + +const sectionFillMaterial = new MeshBasicNodeMaterial({ + color: SECTION_FILL_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.85, + side: THREE.DoubleSide, + transparent: true, +}) + +const sectionOutlineMaterial = new LineBasicNodeMaterial({ + color: SECTION_OUTLINE_COLOR, + depthTest: false, + depthWrite: false, + linewidth: 2, +}) + +const hoverOutlineProxyMaterial = new THREE.MeshBasicMaterial({ + colorWrite: false, + depthWrite: false, + side: THREE.DoubleSide, + transparent: true, + opacity: 0, +}) + +// Half-thickness of the slab brush intersected with the roof shell. The wafer +// must be thin enough to read as a flat cut face but thick enough that CSG +// produces a stable, non-degenerate solid. +const SECTION_SLAB_HALF_THICKNESS = 0.006 + +// Builds a thin oriented slab brush straddling one cut line, spanning the full +// segment height. Intersecting it with the roof shell yields exactly the +// material the cut passes through (roof slab + wall bands), leaving the hollow +// attic empty — a true section cut. +function buildSectionSlabBrush(plane: SectionPlaneSpec, vMin: number, vMax: number): Brush | null { + const span = plane.uMax - plane.uMin + const height = vMax - vMin + if (!(span > 1e-4 && height > 1e-4)) return null + + // Extend past each end only as far as that end allows: a free edge gets an + // overhang allowance so eave/shingle material is captured; a trimmed end gets + // 0 so the slab stops at the cut line and the section never pokes past the + // trim box. The box is centred on the extended span's midpoint. + const uLo = plane.uMin - plane.extendMin + const uHi = plane.uMax + plane.extendMax + const length = uHi - uLo + const geometry = new THREE.BoxGeometry(length, height, SECTION_SLAB_HALF_THICKNESS * 2) + const yaw = Math.atan2(-plane.dir.y, plane.dir.x) + geometry.rotateY(yaw) + const midU = (uLo + uHi) / 2 + const [cx, , cz] = liftSectionPoint(plane, midU, 0) + geometry.translate(cx, (vMin + vMax) / 2, cz) + + const brush = new Brush(geometry) + prepareBrushForCSG(brush) + return brush +} + +// Builds the combined fill + outline geometries (segment-local 3D) for all +// active trim planes. The fill is the CSG intersection of the untrimmed roof +// shell with a thin slab at each cut line (material only — attic stays hollow). +// The outline is the edge silhouette of that same fill (via EdgesGeometry), so +// it traces the real cut shape — wall/deck band boundaries and the hollow-attic +// edge — instead of just the top surface line. Both in segment-local space, to +// be mounted under the segment-world-matrix group. +function buildSectionGeometries( + segment: RoofSegmentNode, + planes: SectionPlaneSpec[], + accessoryGeometries: THREE.BufferGeometry[] = [], +): { fill: THREE.BufferGeometry; outline: THREE.BufferGeometry } | null { + if (planes.length === 0) return null + + // Untrimmed shell — the source we intersect slabs against. Dutch rake boards + // are decorative overhang geometry; slicing them makes the red preview sprout + // tall phantom triangles, so the section fill uses the stable roof shell. + const sectionSourceSegment: RoofSegmentNode = + segment.roofType === 'dutch' + ? { ...segment, trim: ZERO_TRIM, dutchGabletRake: 0 } + : { ...segment, trim: ZERO_TRIM } + const shellGeometry = generateRoofSegmentGeometry(sectionSourceSegment) + const shell = new Brush(shellGeometry) + prepareBrushForCSG(shell) + + const slopeFrame = getSegmentSlopeFrame(segment) + // Span the full material height — wall bands (base→eave) plus the deck/shingle + // wedge above — so the cut face fills completely. The earlier red bars that + // poked below the box were horizontal overshoot (the untrimmed slab dragging + // wall material past a perpendicular cut), now fixed by the per-end slab + // extension clamp; clipping the wall band off here only left gaps. + const vMin = -0.05 + const vMax = + segment.wallHeight + + slopeFrame.activeRh + + segment.deckThickness + + segment.shingleThickness + + 0.5 + + const fillPositions: number[] = [] + const outlinePositions: number[] = [] + // Section cut only needs material presence, not per-face materials — disable + // group bookkeeping on the shared evaluator for this pass so mismatched slab / + // shell material slots can't misalign group indices and crash. + const prevUseGroups = csgEvaluator.useGroups + const prevAttributes = csgEvaluator.attributes + csgEvaluator.useGroups = false + csgEvaluator.attributes = ['position'] + + // Intersect every active section slab with a source solid (the roof shell or + // an accessory mesh), appending the resulting cross-section to the shared + // fill + outline buffers. The slab sits just inside the kept material, so the + // intersection yields the material face the cut exposes. + const sliceSourceBySlabs = (source: Brush) => { + for (const plane of planes) { + const slab = buildSectionSlabBrush(plane, vMin, vMax) + if (!slab) continue + try { + const result = csgEvaluator.evaluate(source, slab, INTERSECTION) as Brush + const geo = result.geometry as THREE.BufferGeometry + const pos = geo.getAttribute('position') as THREE.BufferAttribute | undefined + if (pos && pos.count > 0) { + const index = geo.getIndex() + const count = index ? index.count : pos.count + for (let i = 0; i < count; i++) { + const vi = index ? index.getX(i) : i + fillPositions.push(pos.getX(vi), pos.getY(vi), pos.getZ(vi)) + } + // Trace the silhouette of the cut face itself. EdgesGeometry emits a + // boundary/crease line list; the thin wafer's flat cut caps give a + // crisp outline of the actual material shape. + const edges = new THREE.EdgesGeometry(geo, 1) + const ep = edges.getAttribute('position') as THREE.BufferAttribute | undefined + if (ep) { + for (let i = 0; i < ep.count; i++) { + outlinePositions.push(ep.getX(i), ep.getY(i), ep.getZ(i)) + } + } + edges.dispose() + } + geo.dispose() + } catch (e) { + console.error('Roof section-cut CSG failed:', e) + } finally { + slab.geometry.dispose() + } + } + } + + // Brushes built from accessory meshes; disposed after the slice pass. + const accessoryBrushes: Brush[] = [] + try { + sliceSourceBySlabs(shell) + // Accessories that fall in the trimmed region contribute their own + // cross-section to the same red fill (chimney, vents, skylight, dormer, …). + // Each geometry arrives already in segment-local space. + for (const accGeo of accessoryGeometries) { + try { + // Weld coincident verts so the brush is a valid indexed solid. Most + // accessories are clean THREE primitives, but some (e.g. the ridge + // vent) are hand-wound non-indexed triangle soup; three-bvh-csg's + // INTERSECTION classifies inside/outside off a welded, indexed mesh and + // silently yields nothing for raw soup — the same `mergeVertices` step + // the viewer's own accessory CSG runs before any boolean op. + const welded = mergeVertices(accGeo, 1e-4) + const accBrush = new Brush(welded) + prepareBrushForCSG(accBrush) + accessoryBrushes.push(accBrush) + sliceSourceBySlabs(accBrush) + } catch (e) { + console.error('Roof section-cut accessory CSG failed:', e) + } + } + } finally { + csgEvaluator.useGroups = prevUseGroups + csgEvaluator.attributes = prevAttributes + shellGeometry.dispose() + for (const b of accessoryBrushes) b.geometry.dispose() + } + + if (fillPositions.length === 0) return null + + const fill = new THREE.BufferGeometry() + fill.setAttribute('position', new THREE.Float32BufferAttribute(fillPositions, 3)) + const outline = new THREE.BufferGeometry() + outline.setAttribute('position', new THREE.Float32BufferAttribute(outlinePositions, 3)) + return { fill, outline } +} + +// Zeroed trim — the section fill intersects the FULL (untrimmed) roof shell at +// the cut line, so we regenerate the shell with no trim and slab-intersect it. +const ZERO_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, +} + +type TrimVisibleBounds = ReturnType + +function getTrimVisibleTopBounds(segment: RoofSegmentNode): TrimVisibleBounds { + const bounds = getRoofSegmentVisibleTopBounds(segment) + if (segment.roofType !== 'dutch') return bounds + + const trim = normalizeRoofSegmentTrim(segment) + const metrics = getDutchRoofMetrics(segment) + const requestedRake = Math.max(0, segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake) + const rakeReach = Math.min( + requestedRake, + (metrics.axis === 'x' ? metrics.shoulderInsetAlongWidth : metrics.shoulderInsetAlongDepth) * + 0.98, + ) + if (!(rakeReach > 0.001)) return bounds + + const next = { ...bounds } + if (metrics.axis === 'x') { + if (!(trim.left > 0)) next.minX -= rakeReach + if (!(trim.right > 0)) next.maxX += rakeReach + } else { + if (!(trim.back > 0)) next.minZ -= rakeReach + if (!(trim.front > 0)) next.maxZ += rakeReach + } + + next.width = Math.max(0.01, next.maxX - next.minX) + next.depth = Math.max(0.01, next.maxZ - next.minZ) + return next +} + +// Shape fields that affect the segment's 3D volume. Trim is excluded — the cut +// lines arrive via `planes`, whose endpoints already encode the trim — so the +// memo recomputes when either the roof shape or any trim changes, and the shell +// regeneration only reruns when the actual roof shape changes. +function segmentShapeKey(segment: RoofSegmentNode): string { + return JSON.stringify([ + segment.roofType, + segment.width, + segment.depth, + segment.wallHeight, + segment.pitch, + segment.wallThickness, + segment.deckThickness, + segment.overhang, + segment.shingleThickness, + segment.gambrelLowerWidthRatio, + segment.gambrelLowerHeightRatio, + segment.mansardSteepWidthRatio, + segment.mansardSteepHeightRatio, + segment.dutchHipWidthRatio, + segment.dutchHipHeightRatio, + segment.dutchWaistLengthRatio, + segment.dutchGabletRake, + segment.dutchTopRakeThickness, + ]) +} + +// Collect every roof-accessory mesh hosted on `segment` as a segment-local +// geometry, so the section-cut pass can intersect each with the cut slabs and +// show its cross-section in the red fill. Registry-driven — an accessory is any +// child kind declaring the `roofAccessory` capability — so no kind is named +// here. The meshes come straight from `sceneRegistry` (already live: each +// renderer re-clips against the live trim), so the geometry reflects the +// in-flight drag. Returned geometries are fresh clones the caller owns + +// disposes. +const _segWorldInverse = new THREE.Matrix4() +const _accWorld = new THREE.Matrix4() +function collectAccessorySectionGeometries(segment: RoofSegmentNode): THREE.BufferGeometry[] { + const childIds = segment.children + if (!childIds || childIds.length === 0) return [] + + const segSource = sceneRegistry.nodes.get(segment.id) + if (!segSource) return [] + segSource.updateWorldMatrix(true, false) + _segWorldInverse.copy(segSource.matrixWorld).invert() + + const nodes = useScene.getState().nodes + const out: THREE.BufferGeometry[] = [] + for (const childId of childIds) { + const childNode = nodes[childId as AnyNodeId] + if (!childNode) continue + if (!nodeRegistry.get(childNode.type)?.capabilities?.roofAccessory) continue + const obj = sceneRegistry.nodes.get(childId) + if (!obj) continue + obj.updateWorldMatrix(true, true) + obj.traverse((child) => { + const mesh = child as THREE.Mesh + if (!mesh.isMesh || !mesh.geometry) return + const posAttr = mesh.geometry.getAttribute('position') as THREE.BufferAttribute | undefined + if (!posAttr || posAttr.count === 0) return + // mesh-world → segment-local: segmentWorld⁻¹ · meshWorld. + _accWorld.multiplyMatrices(_segWorldInverse, mesh.matrixWorld) + const geo = mesh.geometry.clone() + geo.applyMatrix4(_accWorld) + out.push(geo) + }) + } + return out +} + +// A change key over the hosted accessories' kinds + world transforms, so the +// section-cut memo recomputes when an accessory moves, is added/removed, or its +// host pose shifts (mirrors how `planes` keys the trim cut). Geometry-shape +// changes are caught by the renderer re-clipping (new mesh world matrix on +// resize is not guaranteed, but trim drag changes `planes` every tick, which +// already forces the recompute during the gesture we care about). +function accessorySectionKey(segment: RoofSegmentNode): string { + const childIds = segment.children + if (!childIds || childIds.length === 0) return 'none' + const nodes = useScene.getState().nodes + const parts: string[] = [] + for (const childId of childIds) { + const childNode = nodes[childId as AnyNodeId] + if (!childNode) continue + if (!nodeRegistry.get(childNode.type)?.capabilities?.roofAccessory) continue + const obj = sceneRegistry.nodes.get(childId) + if (!obj) continue + parts.push(`${childId}:${obj.matrixWorld.elements.map((n) => n.toFixed(3)).join(',')}`) + } + return parts.join('|') +} + +// Renders the cutaway section cut (material fill + cut-edge outline) for the +// active trim planes, in segment-local space (mounted under the +// segment-world-matrix group). The fill is the CSG intersection of the +// untrimmed roof shell (and every hosted accessory) with a thin slab at each +// cut line, so only real material is shown and the hollow attic stays empty; +// the outline is the analytic surface edge. +function SectionCut({ segment, planes }: { segment: RoofSegmentNode; planes: SectionPlaneSpec[] }) { + const shapeKey = segmentShapeKey(segment) + const accessoryKey = accessorySectionKey(segment) + + const geometries = useMemo(() => { + if (planes.length === 0) return null + const accessoryGeometries = collectAccessorySectionGeometries(segment) + const result = buildSectionGeometries(segment, planes, accessoryGeometries) + for (const g of accessoryGeometries) g.dispose() + return result + // Recompute when the roof shape (shapeKey), the cut lines (planes), or the + // hosted accessories (accessoryKey) change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segment, planes.length, planes]) + + useEffect(() => { + return () => { + geometries?.fill.dispose() + geometries?.outline.dispose() + } + }, [geometries]) + + if (!geometries) return null + + return ( + + null} + renderOrder={SECTION_FILL_RENDER_ORDER} + /> + + + ) +} + +function HoveredRoofSegmentOutlineProxy() { + const hoveredId = useViewer((s) => s.hoveredId) + const segment = useScene((s) => { + if (!hoveredId) return null + const node = s.nodes[hoveredId as AnyNodeId] + return node?.type === 'roof-segment' ? (node as RoofSegmentNode) : null + }) + const nodes = useScene((s) => s.nodes) + const ref = useRef(null) + + const geometry = useMemo(() => { + if (!segment) return null + return generateRoofSegmentGeometry(segment, nodes) + }, [nodes, segment]) + const source = segment ? (sceneRegistry.nodes.get(segment.id) ?? null) : null + const roofRoot = + segment?.parentId && nodes[segment.parentId as AnyNodeId]?.type === 'roof' + ? (sceneRegistry.nodes.get(segment.parentId) ?? null) + : null + + useEffect(() => { + return () => { + geometry?.dispose() + } + }, [geometry]) + + useFrame(() => { + const mesh = ref.current + if (!(mesh && source && roofRoot)) return + source.updateWorldMatrix(true, false) + roofRoot.updateWorldMatrix(true, false) + mesh.matrix.copy(roofRoot.matrixWorld).invert().multiply(source.matrixWorld) + mesh.matrixAutoUpdate = false + }) + + if (!(source && geometry && roofRoot && segment)) return null + + return createPortal( + null} + ref={ref} + />, + roofRoot, + ) +} + +const _dragNdc = new THREE.Vector2() +const _dragRaycaster = new THREE.Raycaster() +const _dragPlaneHit = new THREE.Vector3() +const _dragLocalPoint = new THREE.Vector3() +const _dragInverseMatrix = new THREE.Matrix4() +const _trimHitInverseMatrix = new THREE.Matrix4() +const _trimHitRay = new THREE.Ray() +const _trimHitBox = new THREE.Box3() +const _trimHitPoint = new THREE.Vector3() + +function makeExpandedTrimRaycast( + visualScale: readonly [number, number, number], + hitScale: readonly [number, number, number], +) { + const halfX = Math.max(0.5, hitScale[0] / Math.max(visualScale[0], 1e-6) / 2) + const halfY = Math.max(0.5, hitScale[1] / Math.max(visualScale[1], 1e-6) / 2) + const halfZ = Math.max(0.5, hitScale[2] / Math.max(visualScale[2], 1e-6) / 2) + return function expandedTrimRaycast( + this: THREE.Mesh, + raycaster: THREE.Raycaster, + intersects: THREE.Intersection[], + ) { + _trimHitInverseMatrix.copy(this.matrixWorld).invert() + _trimHitRay.copy(raycaster.ray).applyMatrix4(_trimHitInverseMatrix) + _trimHitBox.min.set(-halfX, -halfY, -halfZ) + _trimHitBox.max.set(halfX, halfY, halfZ) + const localHit = _trimHitRay.intersectBox(_trimHitBox, _trimHitPoint) + if (!localHit) return + const point = localHit.clone().applyMatrix4(this.matrixWorld) + const distance = raycaster.ray.origin.distanceTo(point) + if (distance < raycaster.near || distance > raycaster.far) return + intersects.push({ distance, point, object: this }) + } +} + +function trimEquals(a: RoofSegmentTrim, b: RoofSegmentTrim): boolean { + return ( + a.left === b.left && + a.right === b.right && + a.front === b.front && + a.back === b.back && + a.frontLeft === b.frontLeft && + a.frontRight === b.frontRight && + a.backLeft === b.backLeft && + a.backRight === b.backRight && + a.frontLeftX === b.frontLeftX && + a.frontLeftZ === b.frontLeftZ && + a.frontRightX === b.frontRightX && + a.frontRightZ === b.frontRightZ && + a.backLeftX === b.backLeftX && + a.backLeftZ === b.backLeftZ && + a.backRightX === b.backRightX && + a.backRightZ === b.backRightZ + ) +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function isDiagonalTrimSide(side: RoofTrimSide): side is DiagonalTrimSide { + return ( + side === 'frontLeft' || side === 'frontRight' || side === 'backLeft' || side === 'backRight' + ) +} + +function getDiagonalAxisKeys(side: DiagonalTrimSide): [DiagonalTrimAxisKey, DiagonalTrimAxisKey] { + switch (side) { + case 'frontLeft': + return ['frontLeftX', 'frontLeftZ'] + case 'frontRight': + return ['frontRightX', 'frontRightZ'] + case 'backLeft': + return ['backLeftX', 'backLeftZ'] + case 'backRight': + return ['backRightX', 'backRightZ'] + } +} + +function getDiagonalResetCorner(side: RoofTrimSide): DiagonalTrimSide | null { + if (isDiagonalTrimSide(side)) return side + if (side.endsWith('X') || side.endsWith('Z')) { + return getDiagonalAxisCorner(side as DiagonalTrimAxisSide) + } + return null +} + +function getDiagonalAxisCorner(side: DiagonalTrimAxisSide): DiagonalTrimSide { + if (side === 'frontLeftX' || side === 'frontLeftZ') return 'frontLeft' + if (side === 'frontRightX' || side === 'frontRightZ') return 'frontRight' + if (side === 'backLeftX' || side === 'backLeftZ') return 'backLeft' + return 'backRight' +} + +function getOppositeDiagonalAxis(side: DiagonalTrimAxisKey): DiagonalTrimAxisKey { + switch (side) { + case 'frontLeftX': + return 'frontRightX' + case 'frontRightX': + return 'frontLeftX' + case 'backLeftX': + return 'backRightX' + case 'backRightX': + return 'backLeftX' + case 'frontLeftZ': + return 'backLeftZ' + case 'backLeftZ': + return 'frontLeftZ' + case 'frontRightZ': + return 'backRightZ' + case 'backRightZ': + return 'frontRightZ' + } +} + +function getMaxDiagonalAxisTrim( + segment: RoofSegmentNode, + trim: RoofSegmentTrim, + axis: DiagonalTrimAxisKey, +): number { + const keptWidth = Math.max(0, segment.width - trim.left - trim.right) + const keptDepth = Math.max(0, segment.depth - trim.front - trim.back) + const opposite = trim[getOppositeDiagonalAxis(axis)] + const span = axis.endsWith('X') ? keptWidth : keptDepth + return Math.max(0, span - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite) +} + +function getStarterDiagonalTrim(segment: RoofSegmentNode, trim: RoofSegmentTrim): number { + const keptWidth = Math.max(0, segment.width - trim.left - trim.right) + const keptDepth = Math.max(0, segment.depth - trim.front - trim.back) + const maxDiagonalTrim = Math.max(0, Math.min(keptWidth, keptDepth) - MIN_ROOF_SEGMENT_TRIM_SPAN) + return Math.min(maxDiagonalTrim, Math.max(0.75, maxDiagonalTrim * 0.2)) +} + +function patchTrimSide( + segment: RoofSegmentNode, + baseTrim: RoofSegmentTrim, + side: RoofTrimSide, + rawValue: number, +): RoofSegmentTrim { + const next = { ...baseTrim } + if (side === 'left' || side === 'right') { + const opposite = side === 'left' ? baseTrim.right : baseTrim.left + const max = Math.max(0, segment.width - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite) + next[side] = clamp(rawValue, 0, max) + } else { + if (isDiagonalTrimSide(side)) { + const [xAxis, zAxis] = getDiagonalAxisKeys(side) + next[xAxis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, xAxis)) + next[zAxis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, zAxis)) + next[side] = Math.min(next[xAxis], next[zAxis]) + return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next }) + } + + if (side.endsWith('X') || side.endsWith('Z')) { + const axis = side as DiagonalTrimAxisKey + const corner = getDiagonalAxisCorner(axis) + const [xAxis, zAxis] = getDiagonalAxisKeys(corner) + const otherAxis = axis === xAxis ? zAxis : xAxis + const starter = getStarterDiagonalTrim(segment, baseTrim) + next[axis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, axis)) + if (next[otherAxis] <= 0 && next[corner] <= 0) { + next[otherAxis] = Math.min(starter, getMaxDiagonalAxisTrim(segment, baseTrim, otherAxis)) + } + next[corner] = Math.min(next[xAxis], next[zAxis]) + return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next }) + } + + const opposite = side === 'front' ? baseTrim.back : baseTrim.front + const max = Math.max(0, segment.depth - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite) + next[side] = clamp(rawValue, 0, max) + } + + return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next }) +} + +function patchTrimSideByDelta( + segment: RoofSegmentNode, + baseTrim: RoofSegmentTrim, + side: RoofTrimSide, + delta: number, +): RoofSegmentTrim { + if (isDiagonalTrimSide(side)) { + const [xAxis, zAxis] = getDiagonalAxisKeys(side) + const next = { ...baseTrim } + next[xAxis] = clamp( + baseTrim[xAxis] + delta, + 0, + getMaxDiagonalAxisTrim(segment, baseTrim, xAxis), + ) + next[zAxis] = clamp( + baseTrim[zAxis] + delta, + 0, + getMaxDiagonalAxisTrim(segment, baseTrim, zAxis), + ) + next[side] = Math.min(next[xAxis], next[zAxis]) + return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next }) + } + + const baseValue = baseTrim[side] + return patchTrimSide(segment, baseTrim, side, baseValue + delta) +} + +function getTrimValueFromLocalPoint( + segment: RoofSegmentNode, + baseTrim: RoofSegmentTrim, + side: RoofTrimSide, + localPoint: THREE.Vector3, +): number { + const leftX = -segment.width / 2 + baseTrim.left + const rightX = segment.width / 2 - baseTrim.right + const frontZ = segment.depth / 2 - baseTrim.front + const backZ = -segment.depth / 2 + baseTrim.back + + switch (side) { + case 'left': + return localPoint.x + segment.width / 2 + case 'right': + return segment.width / 2 - localPoint.x + case 'front': + return segment.depth / 2 - localPoint.z + case 'back': + return localPoint.z + segment.depth / 2 + case 'frontLeft': + return localPoint.x - leftX + (frontZ - localPoint.z) + case 'frontRight': + return rightX - localPoint.x + (frontZ - localPoint.z) + case 'backLeft': + return localPoint.x - leftX + (localPoint.z - backZ) + case 'backRight': + return rightX - localPoint.x + (localPoint.z - backZ) + case 'frontLeftX': + return localPoint.x - leftX + case 'frontLeftZ': + return frontZ - localPoint.z + case 'frontRightX': + return rightX - localPoint.x + case 'frontRightZ': + return frontZ - localPoint.z + case 'backLeftX': + return localPoint.x - leftX + case 'backLeftZ': + return localPoint.z - backZ + case 'backRightX': + return rightX - localPoint.x + case 'backRightZ': + return localPoint.z - backZ + } +} + +function getTrimLabel(side: RoofTrimSide): string { + switch (side) { + case 'left': + return 'trim left' + case 'right': + return 'trim right' + case 'front': + return 'trim front' + case 'back': + return 'trim back' + case 'frontLeft': + return 'trim front left diagonal' + case 'frontRight': + return 'trim front right diagonal' + case 'backLeft': + return 'trim back left diagonal' + case 'backRight': + return 'trim back right diagonal' + case 'frontLeftX': + return 'trim front left diagonal width' + case 'frontLeftZ': + return 'trim front left diagonal depth' + case 'frontRightX': + return 'trim front right diagonal width' + case 'frontRightZ': + return 'trim front right diagonal depth' + case 'backLeftX': + return 'trim back left diagonal width' + case 'backLeftZ': + return 'trim back left diagonal depth' + case 'backRightX': + return 'trim back right diagonal width' + case 'backRightZ': + return 'trim back right diagonal depth' + } +} + +function getTrimCursor(side: RoofTrimSide): string { + switch (side) { + case 'left': + case 'right': + return 'ew-resize' + case 'front': + case 'back': + return 'ns-resize' + case 'frontLeft': + case 'backRight': + return 'nwse-resize' + case 'frontRight': + case 'backLeft': + return 'nesw-resize' + case 'frontLeftX': + case 'frontRightX': + case 'backLeftX': + case 'backRightX': + return 'ew-resize' + case 'frontLeftZ': + case 'frontRightZ': + case 'backLeftZ': + case 'backRightZ': + return 'ns-resize' + } +} + +function shouldShowTrimPlanes(metadata: unknown): boolean { + return ( + typeof metadata === 'object' && + metadata !== null && + !Array.isArray(metadata) && + (metadata as Record).showTrimPlanes === true + ) +} + +function commitSegmentTrim(segment: RoofSegmentNode, trim: RoofSegmentTrim) { + const scene = useScene.getState() + scene.applyNodeChanges({ + update: [{ id: segment.id as AnyNodeId, data: { trim } as Partial }], + }) +} + +function RoofTrimHandles() { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const selectedId = selectedIds.length === 1 ? (selectedIds[0] as AnyNodeId) : null + const segment = useScene((s) => { + if (!selectedId) return null + const node = s.nodes[selectedId] + return node?.type === 'roof-segment' ? (node as RoofSegmentNode) : null + }) + const readOnly = useScene((s) => s.readOnly) + const liveOverrideKey = useLiveNodeOverrides((s) => + segment ? JSON.stringify(s.overrides.get(segment.id) ?? null) : null, + ) + const [hoveredSide, setHoveredSide] = useState(null) + const [draggingSide, setDraggingSide] = useState(null) + const groupRef = useRef(null) + const dragCleanupRef = useRef<(() => void) | null>(null) + const { camera, gl } = useThree() + const zoom = camera instanceof THREE.OrthographicCamera ? 1 / camera.zoom : 1 + const handleBaseScale = zoom * TRIM_HANDLE_BASE_SCALE + + useEffect(() => () => dragCleanupRef.current?.(), []) + + const liveSegment = useMemo(() => { + if (!segment) return null + void liveOverrideKey + return getEffectiveNode(segment) + }, [segment, liveOverrideKey]) + + useFrame(() => { + if (!liveSegment || !groupRef.current) return + const source = sceneRegistry.nodes.get(liveSegment.id) + if (!source) return + source.updateWorldMatrix(true, false) + groupRef.current.matrix.copy(source.matrixWorld) + groupRef.current.matrixAutoUpdate = false + }) + + const showTrimPlanes = shouldShowTrimPlanes(liveSegment?.metadata) + + if (readOnly || !segment || !liveSegment || !showTrimPlanes) return null + + const trim = normalizeRoofSegmentTrim(liveSegment) + const activeRh = getActiveRoofHeight(liveSegment) + const handleY = Math.max(0.45, liveSegment.wallHeight + activeRh + 0.45) + const keptWidth = Math.max(0.01, liveSegment.width - trim.left - trim.right) + const keptDepth = Math.max(0.01, liveSegment.depth - trim.front - trim.back) + const leftX = -liveSegment.width / 2 + trim.left + const rightX = liveSegment.width / 2 - trim.right + const frontZ = liveSegment.depth / 2 - trim.front + const backZ = -liveSegment.depth / 2 + trim.back + const visibleBounds = getTrimVisibleTopBounds({ + ...liveSegment, + trim: { + ...trim, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, + }, + }) + const visibleCenterX = (visibleBounds.minX + visibleBounds.maxX) / 2 + const visibleCenterZ = (visibleBounds.minZ + visibleBounds.maxZ) / 2 + const visibleWidth = Math.max(0.01, visibleBounds.maxX - visibleBounds.minX) + const visibleDepth = Math.max(0.01, visibleBounds.maxZ - visibleBounds.minZ) + const visualLeftX = trim.left > 0 ? leftX : visibleBounds.minX + const visualRightX = trim.right > 0 ? rightX : visibleBounds.maxX + const visualFrontZ = trim.front > 0 ? frontZ : visibleBounds.maxZ + const visualBackZ = trim.back > 0 ? backZ : visibleBounds.minZ + const maxDiagonalTrim = Math.max(0, Math.min(keptWidth, keptDepth) - MIN_ROOF_SEGMENT_TRIM_SPAN) + + const pointOnTrimLineAtX = ( + start: readonly [number, number], + end: readonly [number, number], + x: number, + ): [number, number] => { + const dx = end[0] - start[0] + if (Math.abs(dx) < 1e-6) return [x, start[1]] + const t = (x - start[0]) / dx + return [x, start[1] + (end[1] - start[1]) * t] + } + + const pointOnTrimLineAtZ = ( + start: readonly [number, number], + end: readonly [number, number], + z: number, + ): [number, number] => { + const dz = end[1] - start[1] + if (Math.abs(dz) < 1e-6) return [start[0], z] + const t = (z - start[1]) / dz + return [start[0] + (end[0] - start[0]) * t, z] + } + + const getDiagonalRailLine = ( + side: DiagonalTrimSide, + start: readonly [number, number], + end: readonly [number, number], + ): [[number, number], [number, number]] => { + switch (side) { + case 'frontLeft': + return [ + pointOnTrimLineAtZ(start, end, visualFrontZ), + pointOnTrimLineAtX(start, end, visualLeftX), + ] + case 'frontRight': + return [ + pointOnTrimLineAtX(start, end, visualRightX), + pointOnTrimLineAtZ(start, end, visualFrontZ), + ] + case 'backLeft': + return [ + pointOnTrimLineAtX(start, end, visualLeftX), + pointOnTrimLineAtZ(start, end, visualBackZ), + ] + case 'backRight': + return [ + pointOnTrimLineAtZ(start, end, visualBackZ), + pointOnTrimLineAtX(start, end, visualRightX), + ] + } + } + + // Cross-section planes the active trim cuts expose. Slice the live roof + // mesh just inside the kept material (the cut sits coplanar with the mesh's + // own face otherwise) so the cutaway shows real construction layers. + const sectionPlanes: SectionPlaneSpec[] = [] + // Inside reference: the kept-region center, on the kept side of every cut so + // the inset always shifts the slice into solid material. + const insideRef: readonly [number, number] = [0, 0] + if (trim.left > 0) { + sectionPlanes.push( + makeSectionPlane(leftX, visualBackZ, leftX, visualFrontZ, SECTION_PLANE_INSET, insideRef), + ) + } + if (trim.right > 0) { + sectionPlanes.push( + makeSectionPlane(rightX, visualBackZ, rightX, visualFrontZ, SECTION_PLANE_INSET, insideRef), + ) + } + if (trim.front > 0) { + sectionPlanes.push( + makeSectionPlane(visualLeftX, frontZ, visualRightX, frontZ, SECTION_PLANE_INSET, insideRef), + ) + } + if (trim.back > 0) { + sectionPlanes.push( + makeSectionPlane(visualLeftX, backZ, visualRightX, backZ, SECTION_PLANE_INSET, insideRef), + ) + } + + // Diagonal/corner cuts run at an angle, so they need a generic vertical + // plane through the corner cut line. The endpoints match the rail line + // geometry in renderDiagonalTrimPlane (start/end before the visual-bounds + // extension; only direction matters for slicing). + const diagonalCutLine = (side: DiagonalTrimSide): [[number, number], [number, number]] | null => { + const [xKey, zKey] = getDiagonalAxisKeys(side) + const dx = trim[xKey] + const dz = trim[zKey] + if (!(dx > 0 && dz > 0)) return null + switch (side) { + case 'frontLeft': + return [ + [leftX + dx, frontZ], + [leftX, frontZ - dz], + ] + case 'frontRight': + return [ + [rightX, frontZ - dz], + [rightX - dx, frontZ], + ] + case 'backLeft': + return [ + [leftX, backZ + dz], + [leftX + dx, backZ], + ] + case 'backRight': + return [ + [rightX - dx, backZ], + [rightX, backZ + dz], + ] + } + } + for (const side of ['frontLeft', 'frontRight', 'backLeft', 'backRight'] as const) { + const line = diagonalCutLine(side) + if (!line) continue + const [s, e] = line + const [railStart, railEnd] = getDiagonalRailLine(side, s, e) + sectionPlanes.push( + makeSectionPlane( + railStart[0], + railStart[1], + railEnd[0], + railEnd[1], + SECTION_PLANE_INSET, + insideRef, + ), + ) + } + + const resetDiagonalTrim = (side: RoofTrimSide, event: ThreeEvent) => { + event.stopPropagation() + const corner = getDiagonalResetCorner(side) + if (!corner) return + + const baseSegment = getEffectiveNode(segment) + const baseTrim = normalizeRoofSegmentTrim(baseSegment) + const next = { ...baseTrim } + const [xAxis, zAxis] = getDiagonalAxisKeys(corner) + next[corner] = 0 + next[xAxis] = 0 + next[zAxis] = 0 + const normalized = normalizeRoofSegmentTrim({ + width: baseSegment.width, + depth: baseSegment.depth, + trim: next, + }) + useLiveNodeOverrides.getState().clear(segment.id as AnyNodeId) + if (!trimEquals(normalized, baseTrim)) { + commitSegmentTrim(baseSegment, normalized) + } + useScene.getState().markDirty(segment.id as AnyNodeId) + } + + const startDrag = (side: RoofTrimSide, event: ThreeEvent) => { + event.stopPropagation() + const source = sceneRegistry.nodes.get(segment.id) + if (!source) return + + source.updateWorldMatrix(true, false) + const startMatrix = source.matrixWorld.clone() + _dragInverseMatrix.copy(startMatrix).invert() + const dragPlanePoint = new THREE.Vector3(0, handleY, 0).applyMatrix4(startMatrix) + const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -dragPlanePoint.y) + const baseSegment = getEffectiveNode(segment) + const baseTrim = normalizeRoofSegmentTrim(baseSegment) + const segmentId = segment.id as AnyNodeId + let pendingTrim = baseTrim + let lastDirtyMarkAt = 0 + let pendingDirtyTimeout: number | null = null + + const clearPendingDirtyTimeout = () => { + if (pendingDirtyTimeout === null) return + window.clearTimeout(pendingDirtyTimeout) + pendingDirtyTimeout = null + } + + const flushDirtyMark = () => { + clearPendingDirtyTimeout() + lastDirtyMarkAt = performance.now() + useScene.getState().markDirty(segmentId) + } + + const scheduleDirtyMark = () => { + const now = performance.now() + if (now - lastDirtyMarkAt >= TRIM_LIVE_REBUILD_INTERVAL_MS) { + flushDirtyMark() + return + } + if (pendingDirtyTimeout !== null) return + pendingDirtyTimeout = window.setTimeout( + () => { + flushDirtyMark() + }, + Math.max(0, TRIM_LIVE_REBUILD_INTERVAL_MS - (now - lastDirtyMarkAt)), + ) + } + + const getPointerTrimValue = (clientX: number, clientY: number): number | null => { + const rect = gl.domElement.getBoundingClientRect() + _dragNdc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -(((clientY - rect.top) / rect.height) * 2 - 1), + ) + _dragRaycaster.setFromCamera(_dragNdc, camera) + if (!_dragRaycaster.ray.intersectPlane(dragPlane, _dragPlaneHit)) return null + _dragLocalPoint.copy(_dragPlaneHit).applyMatrix4(_dragInverseMatrix) + return getTrimValueFromLocalPoint(baseSegment, baseTrim, side, _dragLocalPoint) + } + + const initialPointerValue = getPointerTrimValue(event.clientX, event.clientY) + if (initialPointerValue === null) return + + document.body.style.cursor = getTrimCursor(side) + setDraggingSide(side) + useInteractionScope + .getState() + .begin({ kind: 'handle-drag', nodeId: segmentId, handle: getTrimLabel(side) }) + useViewer.getState().setInputDragging(true) + useScene.temporal.getState().pause() + + const updateFromPointer = (clientX: number, clientY: number) => { + const pointerValue = getPointerTrimValue(clientX, clientY) + if (pointerValue === null) return + pendingTrim = patchTrimSideByDelta( + baseSegment, + baseTrim, + side, + pointerValue - initialPointerValue, + ) + useLiveNodeOverrides.getState().set(segmentId, { trim: pendingTrim }) + // Coalesce live merged-shell rebuilds during trim drag. The override + // still updates every pointer move for local trim affordances, but the + // full roof CSG only refreshes at a capped cadence instead of at raw + // pointer-event frequency. + scheduleDirtyMark() + } + + updateFromPointer(event.clientX, event.clientY) + + const cleanup = () => { + clearPendingDirtyTimeout() + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + if ( + document.body.style.cursor === 'ew-resize' || + document.body.style.cursor === 'ns-resize' || + document.body.style.cursor === 'nwse-resize' || + document.body.style.cursor === 'nesw-resize' || + document.body.style.cursor === 'move' + ) { + document.body.style.cursor = '' + } + useScene.temporal.getState().resume() + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'handle-drag' && scope.nodeId === segmentId) + useViewer.getState().setInputDragging(false) + setDraggingSide(null) + dragCleanupRef.current = null + } + + const onMove = (moveEvent: PointerEvent) => { + updateFromPointer(moveEvent.clientX, moveEvent.clientY) + } + + const onUp = () => { + swallowNextClick() + if (!trimEquals(pendingTrim, baseTrim)) { + commitSegmentTrim(baseSegment, pendingTrim) + } + useLiveNodeOverrides.getState().clear(segmentId) + flushDirtyMark() + cleanup() + } + + const onCancel = () => { + useLiveNodeOverrides.getState().clear(segmentId) + flushDirtyMark() + cleanup() + } + + dragCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } + + const renderTrimPlane = ( + side: RoofTrimSide, + position: [number, number, number], + args: [number, number], + rotation: [number, number, number] = [0, 0, 0], + handles: readonly { side: RoofTrimSide; offsetX: number }[] = [{ side, offsetX: 0 }], + showPlane = true, + ) => { + const [planeWidth, planeHeight] = args + const isHovered = handles.some((handle) => handle.side === hoveredSide) + const railY = planeHeight / 2 + const railVisualHeight = Math.max(0.012, handleBaseScale * 0.022) + const railVisualDepth = Math.max(0.01, handleBaseScale * 0.018) + const railVisualLength = planeWidth + railVisualDepth * 2 + const capSize = Math.max(0.045, handleBaseScale * 0.085) + const primaryHandle = handles[0] ?? { side, offsetX: 0 } + const endpointHandles = handles.slice(1) + + const renderRailHitTarget = ( + handle: { side: RoofTrimSide; offsetX: number }, + scale: [number, number, number], + visual: 'rail' | 'cap', + ) => { + const hovered = hoveredSide === handle.side + const visualScale: [number, number, number] = + visual === 'rail' ? scale : [capSize, capSize, capSize] + const hitScale: [number, number, number] = + visual === 'rail' + ? [scale[0], TRIM_RAIL_HIT_HEIGHT, TRIM_RAIL_HIT_DEPTH] + : [TRIM_CAP_HIT_SIZE, TRIM_CAP_HIT_SIZE, TRIM_CAP_HIT_SIZE] + const resetCorner = getDiagonalResetCorner(handle.side) + return ( + + resetDiagonalTrim(handle.side, event) : undefined + } + onPointerDown={(event) => startDrag(handle.side, event)} + onPointerEnter={(event) => { + event.stopPropagation() + setHoveredSide(handle.side) + document.body.style.cursor = getTrimCursor(handle.side) + }} + onPointerLeave={(event) => { + event.stopPropagation() + if (!dragCleanupRef.current) { + setHoveredSide((current) => (current === handle.side ? null : current)) + document.body.style.cursor = '' + } + }} + renderOrder={TRIM_RAIL_RENDER_ORDER} + scale={visualScale} + /> + + ) + } + + return ( + + {showPlane ? ( + null} + renderOrder={TRIM_PLANE_RENDER_ORDER} + scale={[planeWidth, planeHeight, 1]} + /> + ) : null} + + {renderRailHitTarget( + primaryHandle, + [railVisualLength, railVisualHeight, railVisualDepth], + 'rail', + )} + {endpointHandles.map((handle) => + renderRailHitTarget(handle, [capSize, capSize, capSize], 'cap'), + )} + + ) + } + + const renderDiagonalAddHandle = (side: DiagonalTrimSide) => { + if (maxDiagonalTrim <= 0) return null + + let position: [number, number, number] + let xDir = 1 + let zDir = 1 + switch (side) { + case 'frontLeft': + position = [leftX, handleY, frontZ] + xDir = 1 + zDir = -1 + break + case 'frontRight': + position = [rightX, handleY, frontZ] + xDir = -1 + zDir = -1 + break + case 'backLeft': + position = [leftX, handleY, backZ] + xDir = 1 + zDir = 1 + break + case 'backRight': + position = [rightX, handleY, backZ] + xDir = -1 + zDir = 1 + break + default: + return null + } + + const hovered = hoveredSide === side + const addSize = Math.max(0.055, handleBaseScale * 0.1) + const addVisualScale: [number, number, number] = [addSize, addSize, addSize] + const addHitScale: [number, number, number] = [ + TRIM_CAP_HIT_SIZE, + TRIM_CAP_HIT_SIZE, + TRIM_CAP_HIT_SIZE, + ] + const bracketLength = Math.min(0.55, Math.max(0.28, maxDiagonalTrim * 0.22)) + const bracketHeight = Math.max(0.012, handleBaseScale * 0.022) + const bracketDepth = Math.max(0.01, handleBaseScale * 0.018) + const bracketArmLength = bracketLength + bracketDepth + const bracketVisualScale: [number, number, number] = [ + bracketArmLength, + bracketHeight, + bracketDepth, + ] + const bracketHitScale: [number, number, number] = [ + bracketArmLength, + TRIM_RAIL_HIT_HEIGHT, + TRIM_RAIL_HIT_DEPTH, + ] + const previewAmount = getStarterDiagonalTrim(liveSegment, trim) + + let previewStart: [number, number] + let previewEnd: [number, number] + switch (side) { + case 'frontLeft': + previewStart = [leftX + previewAmount, frontZ] + previewEnd = [leftX, frontZ - previewAmount] + break + case 'frontRight': + previewStart = [rightX, frontZ - previewAmount] + previewEnd = [rightX - previewAmount, frontZ] + break + case 'backLeft': + previewStart = [leftX, backZ + previewAmount] + previewEnd = [leftX + previewAmount, backZ] + break + case 'backRight': + previewStart = [rightX - previewAmount, backZ] + previewEnd = [rightX, backZ + previewAmount] + break + } + + const [previewRailStart, previewRailEnd] = getDiagonalRailLine(side, previewStart, previewEnd) + const previewDx = previewRailEnd[0] - previewRailStart[0] + const previewDz = previewRailEnd[1] - previewRailStart[1] + const previewWidth = Math.hypot(previewDx, previewDz) + const previewYaw = Math.atan2(-previewDz, previewDx) + const handlePointerEnter = (event: ThreeEvent) => { + event.stopPropagation() + setHoveredSide(side) + document.body.style.cursor = getTrimCursor(side) + } + const handlePointerLeave = (event: ThreeEvent) => { + event.stopPropagation() + if (!dragCleanupRef.current) { + setHoveredSide((current) => (current === side ? null : current)) + document.body.style.cursor = '' + } + } + + return ( + + {hovered && previewWidth > 0 ? ( + + null} + renderOrder={TRIM_RAIL_RENDER_ORDER} + scale={[previewWidth + bracketDepth * 2, bracketHeight, bracketDepth]} + /> + + ) : null} + + resetDiagonalTrim(side, event)} + onPointerDown={(event) => startDrag(side, event)} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} + position={[position[0] + (xDir * bracketArmLength) / 2, position[1], position[2]]} + raycast={makeExpandedTrimRaycast(bracketVisualScale, bracketHitScale)} + renderOrder={TRIM_RAIL_RENDER_ORDER} + scale={bracketVisualScale} + /> + resetDiagonalTrim(side, event)} + onPointerDown={(event) => startDrag(side, event)} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} + position={[position[0], position[1], position[2] + (zDir * bracketArmLength) / 2]} + raycast={makeExpandedTrimRaycast(bracketVisualScale, bracketHitScale)} + renderOrder={TRIM_RAIL_RENDER_ORDER} + rotation={[0, zDir > 0 ? -Math.PI / 2 : Math.PI / 2, 0]} + scale={bracketVisualScale} + /> + resetDiagonalTrim(side, event)} + onPointerDown={(event) => startDrag(side, event)} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} + position={position} + raycast={makeExpandedTrimRaycast(addVisualScale, addHitScale)} + renderOrder={TRIM_RAIL_RENDER_ORDER} + scale={addVisualScale} + /> + + ) + } + + const renderDiagonalTrimPlane = (side: DiagonalTrimSide, xAmount: number, zAmount: number) => { + if (maxDiagonalTrim <= 0) { + return null + } + + if (!(xAmount > 0 && zAmount > 0)) { + return renderDiagonalAddHandle(side) + } + + const displayX = xAmount + const displayZ = zAmount + if (!(displayX > 0 && displayZ > 0)) return null + + let start: [number, number] + let end: [number, number] + let xOffset = 0 + let zOffset = 0 + const [xSide, zSide] = getDiagonalAxisKeys(side) + switch (side) { + case 'frontLeft': + start = [leftX + displayX, frontZ] + end = [leftX, frontZ - displayZ] + xOffset = -1 + zOffset = 1 + break + case 'frontRight': + start = [rightX, frontZ - displayZ] + end = [rightX - displayX, frontZ] + zOffset = -1 + xOffset = 1 + break + case 'backLeft': + start = [leftX, backZ + displayZ] + end = [leftX + displayX, backZ] + zOffset = -1 + xOffset = 1 + break + case 'backRight': + start = [rightX - displayX, backZ] + end = [rightX, backZ + displayZ] + xOffset = -1 + zOffset = 1 + break + default: + return null + } + + const [railStart, railEnd] = getDiagonalRailLine(side, start, end) + const dx = railEnd[0] - railStart[0] + const dz = railEnd[1] - railStart[1] + const width = Math.hypot(dx, dz) + const yaw = Math.atan2(-dz, dx) + return renderTrimPlane( + side, + [(railStart[0] + railEnd[0]) / 2, handleY / 2, (railStart[1] + railEnd[1]) / 2], + [width, handleY], + [0, yaw, 0], + [ + { side, offsetX: 0 }, + { side: xSide, offsetX: (width / 2) * xOffset }, + { side: zSide, offsetX: (width / 2) * zOffset }, + ], + false, + ) + } + + return ( + + {sectionPlanes.length > 0 ? ( + + ) : null} + {renderTrimPlane( + 'left', + [visualLeftX, handleY / 2, visibleCenterZ], + [visibleDepth, handleY], + [0, Math.PI / 2, 0], + )} + {renderTrimPlane( + 'right', + [visualRightX, handleY / 2, visibleCenterZ], + [visibleDepth, handleY], + [0, Math.PI / 2, 0], + )} + {renderTrimPlane( + 'front', + [visibleCenterX, handleY / 2, visualFrontZ], + [visibleWidth, handleY], + )} + {renderTrimPlane('back', [visibleCenterX, handleY / 2, visualBackZ], [visibleWidth, handleY])} + {renderDiagonalTrimPlane('frontLeft', trim.frontLeftX, trim.frontLeftZ)} + {renderDiagonalTrimPlane('frontRight', trim.frontRightX, trim.frontRightZ)} + {renderDiagonalTrimPlane('backLeft', trim.backLeftX, trim.backLeftZ)} + {renderDiagonalTrimPlane('backRight', trim.backRightX, trim.backRightZ)} + + ) +} + /** * Imperatively toggles the Three.js visibility of roof objects based on the * editor selection — without causing React re-renders in RoofRenderer. * * Full edit-mode (segment selected): - * - merged-roof mesh is hidden - * - segments-wrapper group is shown (individual segments visible for editing) - * - all children are marked dirty so RoofSystem rebuilds their geometry + * - merged-roof mesh stays VISIBLE — it rebuilds live from each segment's + * trim override, so the edited cutaway matches the clean commit instead of + * exposing the per-segment meshes' abutting end-cap faces + * - segments-wrapper group stays hidden (handles render from RoofTrimHandles) + * - all children are marked dirty so RoofSystem rebuilds the merged shell * * Accessory-reveal mode (a dormer/chimney/etc. hosted on a segment is selected): * - merged-roof mesh stays visible (we don't want the appearance to jump) @@ -55,7 +1766,9 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry { */ export const RoofEditSystem = () => { const selectedIds = useViewer((s) => s.selection.selectedIds) + const movingNode = useMovingNode() const prevActiveRoofIds = useRef(new Set()) + const prevMovingRoofIds = useRef(new Set()) const prevRevealRoofIds = useRef(new Set()) useEffect(() => { @@ -68,6 +1781,10 @@ export const RoofEditSystem = () => { // reveal the wrapper so handle portals into the segment mesh become // visible. Merged stays on. const revealRoofIds = new Set() + // Roofs whose selected segment is currently being moved in 3D. During this + // transient state we reveal the wrapper so the moving segment mesh is + // visible and hide the merged roof to avoid the duplicate shell fighting it. + const movingRoofIds = new Set() for (const id of selectedIds) { const node = nodes[id as AnyNodeId] @@ -87,11 +1804,17 @@ export const RoofEditSystem = () => { } } + if (movingNode?.type === 'roof-segment' && movingNode.parentId) { + movingRoofIds.add(movingNode.parentId) + } + // Union of roofs that need ANY state change this tick. const roofIdsToUpdate = new Set([ ...activeRoofIds, + ...movingRoofIds, ...revealRoofIds, ...prevActiveRoofIds.current, + ...prevMovingRoofIds.current, ...prevRevealRoofIds.current, ]) @@ -102,19 +1825,28 @@ export const RoofEditSystem = () => { const mergedMesh = group.getObjectByName('merged-roof') const segmentsWrapper = group.getObjectByName('segments-wrapper') const isActive = activeRoofIds.has(roofId) + const isMoving = movingRoofIds.has(roofId) const isReveal = revealRoofIds.has(roofId) - if (mergedMesh) mergedMesh.visible = !isActive - if (segmentsWrapper) segmentsWrapper.visible = isActive || isReveal + // Keep the clean merged shell visible during trim editing too (not just + // when deselected). The merged shell rebuilds live from each segment's + // trim override (RoofSystem reads getEffectiveNode), so the dragged + // cutaway matches the commit. Showing the individual per-segment meshes + // instead would expose their abutting end-cap faces (the white planes the + // merged union removes) — exactly what the commit doesn't show. + if (mergedMesh) mergedMesh.visible = !isMoving + if (segmentsWrapper) segmentsWrapper.visible = isReveal || isMoving const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined if (roofNode?.children?.length) { const wasActive = prevActiveRoofIds.current.has(roofId) + const wasMoving = prevMovingRoofIds.current.has(roofId) const wasReveal = prevRevealRoofIds.current.has(roofId) - if (isActive !== wasActive) { + if (isActive !== wasActive || isMoving !== wasMoving) { // Entering / exiting full edit mode: rebuild segment / merged - // geometries. Accessory-reveal doesn't need this — segments - // keep their placeholder; only their visibility flips. + // geometries. Segment-move reveal uses the same rebuild so any + // wrapper mesh previously stripped to an empty placeholder is + // restored before the drag begins. const { markDirty } = useScene.getState() for (const childId of roofNode.children) { markDirty(childId as AnyNodeId) @@ -139,8 +1871,14 @@ export const RoofEditSystem = () => { } prevActiveRoofIds.current = activeRoofIds + prevMovingRoofIds.current = movingRoofIds prevRevealRoofIds.current = revealRoofIds - }, [selectedIds]) + }, [movingNode, selectedIds]) - return null + return ( + <> + + + + ) } diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 7410edb07..95fd2db0b 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -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' diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index f7dc1f92d..0d76ef27b 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -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 +} diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 353e58a4f..7938bd030 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -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' @@ -849,7 +852,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() } diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index b8e1108ff..67edda5db 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -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' diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 5cd769e35..f812dc1cc 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -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>, +): 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>, +): 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>, +): WallNode[] { + return [...getLevelWalls(currentLevelId, nodes), ...getBelowLevelWalls(currentLevelId, nodes)] +} + +// Current-level alignment anchors plus the floor-below wall corners. +function collectRoofAlignmentAnchors( + nodes: Readonly>, + 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(null) const outlineRef = useRef(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 ( @@ -402,21 +606,34 @@ export const RoofTool: React.FC = () => { )} {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && ( - - - - + {roofGhostGeometry && ( + + + + )} + {roofGhostEdges && ( + + + + )} + )} ) diff --git a/packages/editor/src/components/tools/select/select-candidates.ts b/packages/editor/src/components/tools/select/select-candidates.ts index 2f91b80c0..62396af70 100644 --- a/packages/editor/src/components/tools/select/select-candidates.ts +++ b/packages/editor/src/components/tools/select/select-candidates.ts @@ -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) { diff --git a/packages/editor/src/components/tools/shared/cursor-sphere.tsx b/packages/editor/src/components/tools/shared/cursor-sphere.tsx index d403842aa..2fb10094f 100644 --- a/packages/editor/src/components/tools/shared/cursor-sphere.tsx +++ b/packages/editor/src/components/tools/shared/cursor-sphere.tsx @@ -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 { color?: string @@ -40,6 +41,10 @@ export const CursorSphere = forwardRef(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(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. */} - - {/* Center dot — at the ground unless the placement point is elevated */} - {!dotAtTip && ( + {!isSnapping && ( + + {/* Center dot — at the ground unless the placement point is elevated */} + {!dotAtTip && ( + + + + + )} + + {/* Outer ring / glow */} - + - )} - - {/* Outer ring / glow */} - - - - - + + )} {/* Vertical line */} {height > 0 && ( diff --git a/packages/editor/src/components/tools/stair/stair-defaults.ts b/packages/editor/src/components/tools/stair/stair-defaults.ts index 572d9d9e0..a81fa0d0e 100644 --- a/packages/editor/src/components/tools/stair/stair-defaults.ts +++ b/packages/editor/src/components/tools/stair/stair-defaults.ts @@ -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 diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 3daf31cd8..cb54f526e 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -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, diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 8c3832b7a..26a6d606f 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -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 = () => { ) : null })()} + {controlPointTarget && + (() => { + const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point') + return RegistryAffordance ? ( + + + + ) : null + })()} + {tangentTarget && + (() => { + const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent') + return RegistryAffordance ? ( + + + + ) : null + })()} {showMover && movingNode.type !== 'building' && ( void @@ -82,7 +85,9 @@ function ChipRow({ ) if (!onClick) { - return
{body}
+ return ( +
{body}
+ ) } 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 ( + <> + setContinuation('fence', isCurved ? 'continuous' : 'curved')} + shortcut="T" + tooltip="Fence type — click or press T to switch between straight and curved" + /> + 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 ? ( + + ) : null} + + ) +} + const PAINT_SCOPE_ICONS: Record = { single: 'lucide:square', object: 'lucide:box', @@ -260,7 +319,10 @@ export function ContextualHelperPanel({ return (
{snapContext ? : null} - {continuationContext ? : null} + {continuationContext === 'fence' ? : null} + {continuationContext && continuationContext !== 'fence' ? ( + + ) : null} {showPaintScope ? : null} {hints.map((hint) => (
(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. diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx index eb23ca4d0..02795c583 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx @@ -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]) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 78657feb1..7c46a116f 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -157,6 +157,7 @@ const treeNodeByType: Record< 'turbine-vent': RegistryTreeNode, cupola: RegistryTreeNode, 'eyebrow-vent': RegistryTreeNode, + skylight: RegistryTreeNode, roof: RoofTreeNode, stair: StairTreeNode, door: DoorTreeNode, diff --git a/packages/editor/src/hooks/use-ceiling-events.ts b/packages/editor/src/hooks/use-ceiling-events.ts index 27105f8bb..730db810c 100644 --- a/packages/editor/src/hooks/use-ceiling-events.ts +++ b/packages/editor/src/hooks/use-ceiling-events.ts @@ -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 diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index f9f46b8ed..9295e287a 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -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 diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 4be6aba78..f4d4717ba 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -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, diff --git a/packages/editor/src/lib/continuation.ts b/packages/editor/src/lib/continuation.ts index 1a34edbee..e4899bc6c 100644 --- a/packages/editor/src/lib/continuation.ts +++ b/packages/editor/src/lib/continuation.ts @@ -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'], diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 61297d999..188cb84bd 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -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, { diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index db2649a7d..3f5139a24 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -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. diff --git a/packages/editor/src/lib/editor-api.ts b/packages/editor/src/lib/editor-api.ts index c18d29031..1c73ddc9b 100644 --- a/packages/editor/src/lib/editor-api.ts +++ b/packages/editor/src/lib/editor-api.ts @@ -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)) + }, } } diff --git a/packages/editor/src/lib/floorplan/apply-alignment.ts b/packages/editor/src/lib/floorplan/apply-alignment.ts index 6c8349abe..50fa215dc 100644 --- a/packages/editor/src/lib/floorplan/apply-alignment.ts +++ b/packages/editor/src/lib/floorplan/apply-alignment.ts @@ -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, diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 31594338a..4d58f1c57 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -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() diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index d4b972fd1..796fdcea0 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -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 { diff --git a/packages/editor/src/lib/level-selection.ts b/packages/editor/src/lib/level-selection.ts index aed583a8d..94cdc585c 100644 --- a/packages/editor/src/lib/level-selection.ts +++ b/packages/editor/src/lib/level-selection.ts @@ -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', diff --git a/packages/editor/src/lib/roof-duplication.ts b/packages/editor/src/lib/roof-duplication.ts index d46998ce2..379b556f6 100644 --- a/packages/editor/src/lib/roof-duplication.ts +++ b/packages/editor/src/lib/roof-duplication.ts @@ -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 } diff --git a/packages/editor/src/lib/roof-hover-outline-proxy.ts b/packages/editor/src/lib/roof-hover-outline-proxy.ts new file mode 100644 index 000000000..fcb4a169a --- /dev/null +++ b/packages/editor/src/lib/roof-hover-outline-proxy.ts @@ -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) +} diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index 4cbbef3ae..b40f96acc 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -114,7 +114,7 @@ function getNextLevelId(level: LevelNode, nodes: Record) { 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]) diff --git a/packages/editor/src/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts index fb1e4c7d8..2e7f65d89 100644 --- a/packages/editor/src/lib/selection-routing.test.ts +++ b/packages/editor/src/lib/selection-routing.test.ts @@ -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) + }) +}) diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts index 9d978da10..207f7266e 100644 --- a/packages/editor/src/lib/selection-routing.ts +++ b/packages/editor/src/lib/selection-routing.ts @@ -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' } diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index 7ca26fb37..090178711 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -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') diff --git a/packages/editor/src/lib/stair-duplication.ts b/packages/editor/src/lib/stair-duplication.ts index e27626d07..99efe3c11 100644 --- a/packages/editor/src/lib/stair-duplication.ts +++ b/packages/editor/src/lib/stair-duplication.ts @@ -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`) } diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts index 0851bca72..71e162c10 100644 --- a/packages/editor/src/lib/surface-plan-snap.ts +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -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, diff --git a/packages/editor/src/lib/world-grid-snap.ts b/packages/editor/src/lib/world-grid-snap.ts index fc6984a3d..acda5a959 100644 --- a/packages/editor/src/lib/world-grid-snap.ts +++ b/packages/editor/src/lib/world-grid-snap.ts @@ -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] } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 87cdbbe69..9d73d96c0 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( 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 diff --git a/packages/editor/src/store/use-fence-curve-draft.ts b/packages/editor/src/store/use-fence-curve-draft.ts new file mode 100644 index 000000000..9b76a63b4 --- /dev/null +++ b/packages/editor/src/store/use-fence-curve-draft.ts @@ -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((set) => ({ + pointCount: 0, + setPointCount: (count) => set({ pointCount: count }), + reset: () => set({ pointCount: 0 }), +})) + +export default useFenceCurveDraft diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts index 950616245..5af76c441 100644 --- a/packages/editor/src/store/use-interaction-scope.ts +++ b/packages/editor/src/store/use-interaction-scope.ts @@ -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 diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index 75d29fbce..ad3d5a140 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -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 diff --git a/packages/mcp/src/resources/constraints.ts b/packages/mcp/src/resources/constraints.ts index c4047c93e..4a90140fe 100644 --- a/packages/mcp/src/resources/constraints.ts +++ b/packages/mcp/src/resources/constraints.ts @@ -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, diff --git a/packages/mcp/src/tools/construction-tools.test.ts b/packages/mcp/src/tools/construction-tools.test.ts index e4dac3bfd..014a48ff2 100644 --- a/packages/mcp/src/tools/construction-tools.test.ts +++ b/packages/mcp/src/tools/construction-tools.test.ts @@ -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')! diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index 11f85ae8a..1488e57f7 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -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(), diff --git a/packages/mcp/src/tools/find-nodes.ts b/packages/mcp/src/tools/find-nodes.ts index 5c0d84b9a..4abe80bbd 100644 --- a/packages/mcp/src/tools/find-nodes.ts +++ b/packages/mcp/src/tools/find-nodes.ts @@ -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 = [] diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index 26b6a0abc..22f7cc2d0 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -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 } diff --git a/packages/nodes/src/box-vent/floorplan.ts b/packages/nodes/src/box-vent/floorplan.ts index 36c572bce..9bc555788 100644 --- a/packages/nodes/src/box-vent/floorplan.ts +++ b/packages/nodes/src/box-vent/floorplan.ts @@ -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) diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index 26f01e846..f72d71a24 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -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 }) => { > { [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 }) => { > { {geo.cap && ( { {geo.flues && ( { {geo.cricket && ( { {geo.bands && ( = ({ 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 = ({ 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 } diff --git a/packages/nodes/src/cupola/floorplan.ts b/packages/nodes/src/cupola/floorplan.ts index fa6b5cb67..4c0d4941d 100644 --- a/packages/nodes/src/cupola/floorplan.ts +++ b/packages/nodes/src/cupola/floorplan.ts @@ -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) diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index 357c69569..71251ae76 100644 --- a/packages/nodes/src/cupola/renderer.tsx +++ b/packages/nodes/src/cupola/renderer.tsx @@ -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 }) => { > { 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} > - + { // 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) => ( - - {winGeo.glassPanes.map((pane, i) => ( - - ))} - {winGeo.frameBars.map((bar, i) => ( - - ))} - {sillGeo && ( - - )} - - ) + 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 ( + + {winGeo.glassPanes.map((pane, i) => ( + + ))} + {winGeo.frameBars.map((bar, i) => ( + + ))} + {sillGeo && ( + + )} + + ) + } return ( <> diff --git a/packages/nodes/src/downspout/inspector-editors.tsx b/packages/nodes/src/downspout/inspector-editors.tsx index 69fdf06bc..fa5c06ac2 100644 --- a/packages/nodes/src/downspout/inspector-editors.tsx +++ b/packages/nodes/src/downspout/inspector-editors.tsx @@ -41,7 +41,7 @@ export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) { : undefined, ) - if (!gutter || gutter.type !== 'gutter') return null + if (gutter?.type !== 'gutter') return null const storeOutlets = gutter.outlets ?? [] const effectiveOutlets = (override?.outlets as GutterOutlet[] | undefined) ?? storeOutlets const outlet = effectiveOutlets.find((o) => o.id === node.outletId) diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx index 8f071ddac..a86604e4d 100644 --- a/packages/nodes/src/downspout/renderer.tsx +++ b/packages/nodes/src/downspout/renderer.tsx @@ -21,6 +21,7 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { computeEaveY } from '../gutter/eave-snap' import { resolveGutterOutletById } from '../gutter/outlet-lookup' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildDownspoutGeometry } from './geometry' import { computeDownspoutRouting } from './routing' @@ -143,6 +144,31 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + // Map downspout-local geometry into the host segment's local frame (where the + // trim cut prisms live). Recompose the same outlet pose the inner mesh group + // is mounted with (gutter offset + rotation → outlet → eave Y). Computed + // before the early returns so the hook order stays stable. + const localToSegment = useMemo(() => { + if (!effectiveGutter || !effectiveSegment) return new THREE.Matrix4() + const outlet = resolveGutterOutletById(effectiveGutter, node.outletId) + if (!outlet) return new THREE.Matrix4() + const liveEaveY = computeEaveY(effectiveSegment) + const gutterRotY = effectiveGutter.rotation ?? 0 + const gutterX = effectiveGutter.position[0] ?? 0 + const gutterZ = effectiveGutter.position[2] ?? 0 + const cos = Math.cos(gutterRotY) + const sin = Math.sin(gutterRotY) + const outletSegX = gutterX + (outlet.x * cos + outlet.z * sin) + const outletSegZ = gutterZ + (-outlet.x * sin + outlet.z * cos) + const outletSegY = liveEaveY + outlet.y + return new THREE.Matrix4().compose( + new THREE.Vector3(outletSegX, outletSegY, outletSegZ), + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), gutterRotY), + new THREE.Vector3(1, 1, 1), + ) + }, [effectiveGutter, effectiveSegment, node.outletId]) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment) + if (!effectiveGutter || !effectiveSegment) return null const outlet = resolveGutterOutletById(effectiveGutter, node.outletId) if (!outlet) return null @@ -177,7 +203,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { > (node.gutterId as AnyNodeId) - if (!gutter || gutter.type !== 'gutter') return null + if (gutter?.type !== 'gutter') return null const segment = gutter.roofSegmentId ? sceneApi.get(gutter.roofSegmentId as AnyNodeId) : undefined diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index 45903b3ab..4ada9816f 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -225,7 +225,7 @@ const DuctTerminalTool = () => { const nodes = useScene.getState().nodes let best: { hit: Vector3; height: number } | null = null for (const node of Object.values(nodes)) { - if (!node || node.type !== 'ceiling') continue + if (node?.type !== 'ceiling') continue if (resolveLevelId(node, nodes) !== activeLevelId) continue const ceiling = node as { height?: number diff --git a/packages/nodes/src/eyebrow-vent/floorplan.ts b/packages/nodes/src/eyebrow-vent/floorplan.ts index 9acd4b87e..d550d87b9 100644 --- a/packages/nodes/src/eyebrow-vent/floorplan.ts +++ b/packages/nodes/src/eyebrow-vent/floorplan.ts @@ -19,10 +19,10 @@ export function buildEyebrowVentFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null const cosR = Math.cos(-roof.rotation) const sinR = Math.sin(-roof.rotation) diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index eb9bf0933..cf019e6c6 100644 --- a/packages/nodes/src/eyebrow-vent/renderer.tsx +++ b/packages/nodes/src/eyebrow-vent/renderer.tsx @@ -19,6 +19,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildEyebrowVentGeometry } from './geometry' const defaultMaterial = new THREE.MeshStandardMaterial({ @@ -83,6 +84,19 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat) }, [surfaceQuat, node.rotation, yAxis]) + // Map vent-local geometry into the host segment's local frame (where the trim + // cut prisms live) — same pose the inner mesh group is mounted with. + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0), + composedQuat, + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[1], node.position[2], composedQuat], + ) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment) + if (!segment) return null const segPos = segment.position ?? [0, 0, 0] @@ -98,7 +112,7 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => > [] = [ - fenceSideMoveHandle('front'), - fenceSideMoveHandle('back'), - fenceHeightHandle(), - fenceCornerPicker('start'), - fenceCornerPicker('end'), -] +const TANGENT_HANDLE_ARM_SCALE = 3 + +function fenceControlPointPicker(index: number): HandleDescriptor { + return { + kind: 'tap-action', + shape: 'corner-picker', + cursor: 'move', + nodeHeight: (n) => n.height ?? 1.8, + onActivate: (node, _scene, editor) => editor.engageControlPointMove(node, index), + placement: { + position: (n) => { + const point = n.path?.[index] ?? n.start + return [point[0], 0, point[1]] + }, + }, + } +} + +function fenceTangentPicker(index: number, side: 'in' | 'out'): HandleDescriptor { + const sign = side === 'out' ? 1 : -1 + return { + kind: 'tap-action', + shape: 'corner-picker', + round: true, + cursor: 'move', + nodeHeight: (n) => (n.height ?? 1.8) * 0.6, + onActivate: (node, _scene, editor) => editor.engageTangentMove(node, index, side), + placement: { + position: (n) => { + const point = n.path?.[index] ?? n.start + if (!n.path) return [point[0], 0, point[1]] + const handle = getFenceControlHandle(n.path, n.tangents, index) + return [ + point[0] + sign * handle.x * TANGENT_HANDLE_ARM_SCALE, + 0, + point[1] + sign * handle.y * TANGENT_HANDLE_ARM_SCALE, + ] + }, + }, + } +} + +const fenceHandles = (node: FenceNodeType): HandleDescriptor[] => { + if (isSplineFence(node) && node.path) { + return [ + fenceHeightHandle(), + ...node.path.flatMap((_, index) => [ + fenceControlPointPicker(index), + fenceTangentPicker(index, 'out'), + fenceTangentPicker(index, 'in'), + ]), + ] + } + + return [ + fenceSideMoveHandle('front'), + fenceSideMoveHandle('back'), + fenceHeightHandle(), + fenceCornerPicker('start'), + fenceCornerPicker('end'), + ] +} /** * Fence — Phase 5 batch kind. Stage B complete: `def.geometry` drives @@ -209,6 +277,8 @@ export const fenceDefinition: NodeDefinition = { // pointer-up). floorplanAffordances: { 'move-endpoint': fenceMoveEndpointAffordance, + 'move-control-point': fenceControlPointAffordance, + 'move-tangent': fenceTangentAffordance, curve: fenceCurveAffordance, }, // Body move on the fence is driven by the two `move-arrow` chevrons @@ -225,6 +295,8 @@ export const fenceDefinition: NodeDefinition = { affordanceTools: { curve: () => import('./curve-tool'), 'move-endpoint': () => import('./move-endpoint-tool'), + 'move-control-point': () => import('./move-control-point-tool'), + 'move-tangent': () => import('./move-tangent-tool'), move: () => import('./move-tool'), }, diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index da4487be9..49bfa747b 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -16,6 +16,7 @@ import { type FencePlanPoint, getSegmentGridStep, isAngleSnapActive, + isGridSnapActive, isMagneticSnapActive, isSegmentLongEnough, snapBuildingLocalToWorldGrid, @@ -43,6 +44,13 @@ import { const LINKED_FENCE_ENDPOINT_EPSILON = 0.025 type FenceEndpointPayload = { fenceId: AnyNodeId; endpoint: 'start' | 'end' } +type FenceControlPointPayload = { fenceId: AnyNodeId; index: number } +type FenceTangentPayload = { fenceId: AnyNodeId; index: number; side: 'in' | 'out' } + +// Must match the floorplan builder's TANGENT_HANDLE_ARM_SCALE: the on-screen +// arm is this many times the raw tangent vector, so dividing the dragged +// offset back out recovers the stored tangent. +const TANGENT_HANDLE_ARM_SCALE = 3 function pointsNearlyEqual(a: FencePlanPoint, b: FencePlanPoint): boolean { return ( @@ -88,7 +96,7 @@ function collectLinkedFences( /** * Fence curve sagitta drag — 1:1 mirror of `wallCurveAffordance`. Drag * projects the pointer onto the chord normal to compute `curveOffset`, - * snaps to grid (Shift bypasses), clamps to `getMaxWallCurveOffset`, + * snaps to grid when that mode is active, clamps to `getMaxWallCurveOffset`, * normalizes via `normalizeWallCurveOffset`. Same single-undo dance — the * dispatcher handles snapshot / pause / resume around `apply`. Lives in * the same file as the endpoint affordance to keep the two fence @@ -105,17 +113,16 @@ export const fenceCurveAffordance: FloorplanAffordance = { return { affectedIds: [node.id], apply({ planPoint, modifiers }) { - const snapStep = getSegmentGridStep() - const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep) - const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep) + const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0 + const x = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0] + const y = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1] const offsetFromMidpoint = -( (x - chord.midpoint.x) * chord.normal.x + (y - chord.midpoint.y) * chord.normal.y ) - const snappedOffset = modifiers.shiftKey - ? offsetFromMidpoint - : snapScalarToGrid(offsetFromMidpoint, snapStep) + const snappedOffset = + snapStep > 0 ? snapScalarToGrid(offsetFromMidpoint, snapStep) : offsetFromMidpoint const nextCurveOffset = normalizeWallCurveOffset( node, Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)), @@ -136,6 +143,114 @@ export const fenceCurveAffordance: FloorplanAffordance = { }, } +/** + * Spline control-point drag — reshapes one point of the fence `path`. Grid + * snap follows the active mode; start/end stay pinned to the path ends so endpoint- + * dependent code stays valid. Publishes a live override per tick, commits the + * final path as one tracked change. No linked-fence cascade: a spline's shape + * is self-contained. + */ +export const fenceControlPointAffordance: FloorplanAffordance = { + start({ node, payload }): FloorplanAffordanceSession { + const { index } = payload as FenceControlPointPayload + const fenceId = node.id as AnyNodeId + const originalPath: FencePlanPoint[] = (node.path ?? []).map((p) => [p[0], p[1]]) + let lastPath = originalPath + + const buildPatch = (point: FencePlanPoint): Record => { + const nextPath = originalPath.map((p, i): FencePlanPoint => (i === index ? point : p)) + lastPath = nextPath + const patch: Record = { path: nextPath } + if (index === 0) patch.start = point + if (index === nextPath.length - 1) patch.end = point + return patch + } + + return { + affectedIds: [fenceId], + apply({ planPoint, modifiers }) { + const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0 + const x = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0] + const y = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1] + useLiveNodeOverrides.getState().set(fenceId, buildPatch([x, y])) + useScene.getState().markDirty(fenceId) + }, + canCommit() { + return lastPath.length >= 2 + }, + commit() { + const data: Partial = { path: lastPath } + data.start = lastPath[0] + data.end = lastPath[lastPath.length - 1] + useScene.getState().updateNodes([{ id: fenceId, data }]) + useLiveNodeOverrides.getState().clear(fenceId) + }, + } + }, +} + +/** + * Spline tangent-handle drag — bends the curve through one control point. The + * dragged end (in / out) gives the OUT-handle vector (negated for the IN end); + * the IN handle is always the mirror so the curve stays smooth (symmetric). + * The visual arm is `TANGENT_HANDLE_ARM_SCALE`× the stored vector, so we divide + * that factor out before storing. Writes `tangents[index]`, padding the array + * to the path length with nulls so untouched points keep their auto tangent. + */ +export const fenceTangentAffordance: FloorplanAffordance = { + start({ node, payload }): FloorplanAffordanceSession { + const { index, side } = payload as FenceTangentPayload + const fenceId = node.id as AnyNodeId + const path = node.path ?? [] + const anchor = path[index] ?? node.start + let lastTangents: Array<[number, number] | null> = (node.tangents ?? []).map((t) => + t ? [t[0], t[1]] : null, + ) + + const buildTangents = (vec: [number, number]): Array<[number, number] | null> => { + const next: Array<[number, number] | null> = Array.from( + { length: path.length }, + (_, i) => lastTangents[i] ?? null, + ) + next[index] = vec + lastTangents = next + return next + } + + return { + affectedIds: [fenceId], + apply({ planPoint, modifiers }) { + const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0 + const px = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0] + const py = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1] + // Arm vector from the anchor to the dragged handle, in plan meters. + let armX = px - anchor[0] + let armY = py - anchor[1] + // The IN end is the mirror, so its drag describes the negated OUT vector. + if (side === 'in') { + armX = -armX + armY = -armY + } + const vec: [number, number] = [ + armX / TANGENT_HANDLE_ARM_SCALE, + armY / TANGENT_HANDLE_ARM_SCALE, + ] + useLiveNodeOverrides.getState().set(fenceId, { tangents: buildTangents(vec) }) + useScene.getState().markDirty(fenceId) + }, + canCommit() { + return true + }, + commit() { + useScene + .getState() + .updateNodes([{ id: fenceId, data: { tangents: lastTangents } as Partial }]) + useLiveNodeOverrides.getState().clear(fenceId) + }, + } + }, +} + export const fenceMoveEndpointAffordance: FloorplanAffordance = { start({ node, payload, nodes }): FloorplanAffordanceSession { const { endpoint } = payload as FenceEndpointPayload diff --git a/packages/nodes/src/fence/floorplan-move.ts b/packages/nodes/src/fence/floorplan-move.ts index b3ae41059..5a3db0152 100644 --- a/packages/nodes/src/fence/floorplan-move.ts +++ b/packages/nodes/src/fence/floorplan-move.ts @@ -6,7 +6,12 @@ import { useLiveNodeOverrides, useScene, } from '@pascal-app/core' -import { getSegmentGridStep, isSegmentLongEnough, snapPointToGrid } from '@pascal-app/editor' +import { + getSegmentGridStep, + isGridSnapActive, + isSegmentLongEnough, + snapPointToGrid, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' type PlanPoint = [number, number] @@ -15,7 +20,7 @@ function pointsEqual(a: PlanPoint, b: PlanPoint): boolean { return a[0] === b[0] && a[1] === b[1] } -type LinkedFenceSnapshot = { id: AnyNodeId; start: PlanPoint; end: PlanPoint } +type LinkedFenceSnapshot = { id: AnyNodeId; start: PlanPoint; end: PlanPoint; path?: PlanPoint[] } function getLinkedFenceSnapshots(args: { fenceId: AnyNodeId @@ -40,12 +45,21 @@ function getLinkedFenceSnapshots(args: { id: fence.id as AnyNodeId, start: [fence.start[0], fence.start[1]], end: [fence.end[0], fence.end[1]], + path: fence.path?.map((point) => [point[0], point[1]]), }) } } return snapshots } +function translatePath( + path: PlanPoint[] | undefined, + dx: number, + dz: number, +): PlanPoint[] | undefined { + return path?.map((point) => [point[0] + dx, point[1] + dz]) +} + /** * 2D floor-plan body move for fence. Mirrors `wallFloorplanMoveTarget` * but without bridge-wall planning: fence corners cascade through @@ -76,23 +90,32 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node let lastDelta: PlanPoint = [0, 0] let lastNextStart: PlanPoint = originalStart let lastNextEnd: PlanPoint = originalEnd + let lastNextPath: PlanPoint[] | undefined = node.path?.map((point) => [point[0], point[1]]) const projectLinked = ( snapshot: LinkedFenceSnapshot, nextStart: PlanPoint, nextEnd: PlanPoint, - ): { start: PlanPoint; end: PlanPoint } => ({ - start: pointsEqual(snapshot.start, originalStart) + dx: number, + dz: number, + ): { start: PlanPoint; end: PlanPoint; path?: PlanPoint[] } => { + const start = pointsEqual(snapshot.start, originalStart) ? nextStart : pointsEqual(snapshot.start, originalEnd) ? nextEnd - : snapshot.start, - end: pointsEqual(snapshot.end, originalStart) + : snapshot.start + const end = pointsEqual(snapshot.end, originalStart) ? nextStart : pointsEqual(snapshot.end, originalEnd) ? nextEnd - : snapshot.end, - }) + : snapshot.end + + return { + start, + end, + path: translatePath(snapshot.path, dx, dz), + } + } const session: FloorplanMoveTargetSession = { affectedIds: [fenceId, ...linkedOriginals.map((l) => l.id)], @@ -104,10 +127,8 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node } const rawDx = planPoint[0] - rawAnchor[0] const rawDz = planPoint[1] - rawAnchor[1] - const step = getSegmentGridStep() - const nextStart: PlanPoint = modifiers.shiftKey - ? [originalStart[0] + rawDx, originalStart[1] + rawDz] - : snapPointToGrid([originalStart[0] + rawDx, originalStart[1] + rawDz], step) + const step = isGridSnapActive() ? getSegmentGridStep() : 0 + const nextStart = snapPointToGrid([originalStart[0] + rawDx, originalStart[1] + rawDz], step) const dx = nextStart[0] - originalStart[0] const dz = nextStart[1] - originalStart[1] if (dx === lastDelta[0] && dz === lastDelta[1]) return @@ -115,17 +136,29 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node const nextEnd: PlanPoint = [originalEnd[0] + dx, originalEnd[1] + dz] lastNextStart = nextStart lastNextEnd = nextEnd + lastNextPath = translatePath( + node.path?.map((point) => [point[0], point[1]]), + dx, + dz, + ) const linkedUpdates = modifiers.altKey ? [] - : linkedOriginals.map((l) => ({ id: l.id, ...projectLinked(l, nextStart, nextEnd) })) + : linkedOriginals.map((l) => ({ + id: l.id, + ...projectLinked(l, nextStart, nextEnd, dx, dz), + })) useLiveNodeOverrides .getState() .setMany([ - [fenceId, { start: nextStart, end: nextEnd }], + [fenceId, { start: nextStart, end: nextEnd, path: lastNextPath }], ...linkedUpdates.map( - (u) => [u.id, { start: u.start, end: u.end }] as [string, Record], + (u) => + [u.id, { start: u.start, end: u.end, path: u.path }] as [ + string, + Record, + ], ), ]) const sceneState = useScene.getState() @@ -152,20 +185,22 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node data: { start: lastNextStart, end: lastNextEnd, + path: lastNextPath, metadata: { ...originalMetadata, isNew: false }, } as Partial, } - : { id: fenceId, data: { start: lastNextStart, end: lastNextEnd } } + : { id: fenceId, data: { start: lastNextStart, end: lastNextEnd, path: lastNextPath } } const linkedUpdates = linkedOriginals.map((l) => ({ id: l.id, - ...projectLinked(l, lastNextStart, lastNextEnd), + ...projectLinked(l, lastNextStart, lastNextEnd, lastDelta[0], lastDelta[1]), })) - useScene - .getState() - .updateNodes([ - fenceUpdate, - ...linkedUpdates.map((u) => ({ id: u.id, data: { start: u.start, end: u.end } })), - ]) + useScene.getState().updateNodes([ + fenceUpdate, + ...linkedUpdates.map((u) => ({ + id: u.id, + data: { start: u.start, end: u.end, path: u.path }, + })), + ]) const overrides = useLiveNodeOverrides.getState() overrides.clear(fenceId) for (const l of linkedOriginals) overrides.clear(l.id) diff --git a/packages/nodes/src/fence/floorplan.ts b/packages/nodes/src/fence/floorplan.ts index 17297f8fe..4c6f23d44 100644 --- a/packages/nodes/src/fence/floorplan.ts +++ b/packages/nodes/src/fence/floorplan.ts @@ -1,11 +1,13 @@ import { type FloorplanGeometry, type GeometryContext, - getWallCurveFrameAt, - getWallCurveLength, + getFenceCenterlineFrameAt, + getFenceCenterlineLength, + getFenceControlHandle, getWallMidpointHandlePoint, isCurvedWall, - sampleWallCenterline, + isSplineFence, + sampleFenceCenterline, } from '@pascal-app/core' import type { FenceNode } from './schema' @@ -32,14 +34,22 @@ import type { FenceNode } from './schema' * in the legacy panel and is fence-specific, so it lives with the kind. */ +// The tangent handle arm is drawn this many times longer than the raw curve +// handle vector so it's easy to grab on screen even at the default (small) +// tangent. The `move-tangent` affordance divides this factor back out so the +// stored tangent matches the visual arm length. Must stay in sync with the +// 3D tool's arm scale. +const TANGENT_HANDLE_ARM_SCALE = 3 + function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)) } function getFloorplanFenceLength(fence: FenceNode): number { - return isCurvedWall(fence) - ? getWallCurveLength(fence) - : Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) + if (isSplineFence(fence) || isCurvedWall(fence)) { + return getFenceCenterlineLength(fence) + } + return Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) } /** @@ -217,14 +227,15 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo const isActive = isSelected || isHighlighted const showInteractiveChrome = isActive || isHovered - // Centerline path — sampled for curved fences so the underlay / + // Centerline path — sampled for spline / arc fences so the underlay / // accent / glow all trace the same shape. - const centerlinePoints = isCurvedWall(node) - ? sampleWallCenterline(node, 24) - : [ - { x: node.start[0], y: node.start[1] }, - { x: node.end[0], y: node.end[1] }, - ] + const centerlinePoints = + isSplineFence(node) || isCurvedWall(node) + ? sampleFenceCenterline(node, 24) + : [ + { x: node.start[0], y: node.start[1] }, + { x: node.end[0], y: node.end[1] }, + ] const pathD = buildCenterlinePathD(centerlinePoints) // Stroke shifts: selected wins; hover (not selected) → `wallHoverStroke` @@ -259,7 +270,7 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo // still sees end posts (matches legacy). const markerTs = getFloorplanFenceMarkerTs(node) const markerFrames = markerTs.map((t) => { - const frame = getWallCurveFrameAt(node, t) + const frame = getFenceCenterlineFrameAt(node, t) return { point: frame.point, angle: Math.atan2(frame.tangent.y, frame.tangent.x), @@ -329,36 +340,113 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo ) } - // 5. Hit-line for click detection. - children.push({ - kind: 'hit-line', - x1: node.start[0], - y1: node.start[1], - x2: node.end[0], - y2: node.end[1], - strokeWidthPx: 18, - cursor: 'pointer', - }) + // 5. Hit-line(s) for click detection. A straight/arc fence uses a single + // chord-spanning line; a spline fence emits one short hit-line per sampled + // span so the clickable region follows the curve instead of cutting the + // chord (there's no curved `hit-path` primitive yet). + if (isSplineFence(node)) { + for (let i = 1; i < centerlinePoints.length; i += 1) { + const a = centerlinePoints[i - 1]! + const b = centerlinePoints[i]! + children.push({ + kind: 'hit-line', + x1: a.x, + y1: a.y, + x2: b.x, + y2: b.y, + strokeWidthPx: 18, + cursor: 'pointer', + }) + } + } else { + children.push({ + kind: 'hit-line', + x1: node.start[0], + y1: node.start[1], + x2: node.end[0], + y2: node.end[1], + strokeWidthPx: 18, + cursor: 'pointer', + }) + } // 6. Endpoint handles + side move-arrows + curve handle + length when // selected. Mirrors the wall builder so fences gain the same set of // in-plan affordances (drag endpoints, drag body via either side // arrow, drag the midpoint sagitta to curve). if (isSelected) { - children.push({ - kind: 'endpoint-handle', - point: [node.start[0], node.start[1]], - state: 'idle', - affordance: 'move-endpoint', - payload: { fenceId: node.id, endpoint: 'start' as const }, - }) - children.push({ - kind: 'endpoint-handle', - point: [node.end[0], node.end[1]], - state: 'idle', - affordance: 'move-endpoint', - payload: { fenceId: node.id, endpoint: 'end' as const }, - }) + if (isSplineFence(node) && node.path) { + // Spline fence: per control point draw (a) the symmetric tangent line + // through the point with a small handle dot on each end — dragging an + // end bends the curve on both sides via `move-tangent` — and (b) the + // larger control-point dot itself, which moves the point. + for (let i = 0; i < node.path.length; i += 1) { + const point = node.path[i]! + const handle = getFenceControlHandle(node.path, node.tangents, i) + // Scale the on-screen handle arm so even the default (small) tangent + // is grabbable; the affordance divides this back out on apply. + const armX = handle.x * TANGENT_HANDLE_ARM_SCALE + const armY = handle.y * TANGENT_HANDLE_ARM_SCALE + const out: [number, number] = [point[0] + armX, point[1] + armY] + const inn: [number, number] = [point[0] - armX, point[1] - armY] + + // Connecting line (the "tangent" through the point). Violet to match + // the 3D tangent line + the handle dots. + children.push({ + kind: 'line', + x1: inn[0], + y1: inn[1], + x2: out[0], + y2: out[1], + stroke: '#8381ed', + strokeWidth: 1.25, + strokeOpacity: 0.85, + vectorEffect: 'non-scaling-stroke', + }) + // Handle dot on each end. Both drive the same `move-tangent` + // affordance; `side` tells it which end is being dragged so the + // stored OUT vector gets the correct sign. + children.push({ + kind: 'endpoint-handle', + point: out, + state: 'idle', + variant: 'curve', + affordance: 'move-tangent', + payload: { fenceId: node.id, index: i, side: 'out' as const }, + }) + children.push({ + kind: 'endpoint-handle', + point: inn, + state: 'idle', + variant: 'curve', + affordance: 'move-tangent', + payload: { fenceId: node.id, index: i, side: 'in' as const }, + }) + // The control-point dot last so it sits on top of the tangent line. + children.push({ + kind: 'endpoint-handle', + point: [point[0], point[1]], + state: 'idle', + affordance: 'move-control-point', + payload: { fenceId: node.id, index: i }, + }) + } + } else { + children.push({ + kind: 'endpoint-handle', + point: [node.start[0], node.start[1]], + state: 'idle', + affordance: 'move-endpoint', + payload: { fenceId: node.id, endpoint: 'start' as const }, + }) + children.push({ + kind: 'endpoint-handle', + point: [node.end[0], node.end[1]], + state: 'idle', + affordance: 'move-endpoint', + payload: { fenceId: node.id, endpoint: 'end' as const }, + }) + } // Two perpendicular `move-arrow` chevrons at the fence midpoint. // No `affordance` → the registry layer routes pointer-down through @@ -371,7 +459,8 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo const dz = node.end[1] - node.start[1] const lineLength = Math.hypot(dx, dz) if (lineLength > 1e-6) { - const frame = isCurvedWall(node) ? getWallCurveFrameAt(node, 0.5) : null + const frame = + isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null const midX = frame ? frame.point.x : (node.start[0] + node.end[0]) / 2 const midZ = frame ? frame.point.y : (node.start[1] + node.end[1]) / 2 const nx = frame ? frame.normal.x : -dz / lineLength @@ -393,29 +482,35 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo // Curve sagitta handle — teal dot at the visual midpoint that drives // `curveOffset`. Routes through `fenceCurveAffordance`. Fences host // no children, so there's no equivalent of wall's curve-blocking - // check to gate this. - const curveHandle = getWallMidpointHandlePoint(node) - children.push({ - kind: 'endpoint-handle', - point: [curveHandle.x, curveHandle.y], - state: 'idle', - variant: 'curve', - affordance: 'curve', - payload: { fenceId: node.id }, - }) + // check to gate this. Suppressed for spline fences: the single sagitta + // is meaningless against a multi-point curve. + if (!isSplineFence(node)) { + const curveHandle = getWallMidpointHandlePoint(node) + children.push({ + kind: 'endpoint-handle', + point: [curveHandle.x, curveHandle.y], + state: 'idle', + variant: 'curve', + affordance: 'curve', + payload: { fenceId: node.id }, + }) + } - const length = getWallCurveLength(node) + const length = getFloorplanFenceLength(node) if (length >= 0.1) { - const midX = (node.start[0] + node.end[0]) / 2 - const midZ = (node.start[1] + node.end[1]) / 2 - const dx = node.end[0] - node.start[0] - const dz = node.end[1] - node.start[1] + const labelFrame = + isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null + const midX = labelFrame ? labelFrame.point.x : (node.start[0] + node.end[0]) / 2 + const midZ = labelFrame ? labelFrame.point.y : (node.start[1] + node.end[1]) / 2 + const angle = labelFrame + ? Math.atan2(labelFrame.tangent.y, labelFrame.tangent.x) + : Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) children.push({ kind: 'dimension-label', cx: midX, cy: midZ, text: `${Number.parseFloat(length.toFixed(2))}m`, - angle: Math.atan2(dz, dx), + angle, }) } } diff --git a/packages/nodes/src/fence/move-control-point-tool.tsx b/packages/nodes/src/fence/move-control-point-tool.tsx new file mode 100644 index 000000000..0656d9b55 --- /dev/null +++ b/packages/nodes/src/fence/move-control-point-tool.tsx @@ -0,0 +1,129 @@ +'use client' + +import { + type AnyNodeId, + emitter, + type FenceNode, + type GridEvent, + pauseSceneHistory, + resumeSceneHistory, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { + CursorSphere, + getSegmentGridStep, + isGridSnapActive, + markToolCancelConsumed, + snapScalarToGrid, + triggerSFX, + useInteractionScope, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useState } from 'react' + +export const MoveFenceControlPointTool: React.FC<{ + target: { fence: FenceNode; index: number } +}> = ({ target }) => { + const fenceId = target.fence.id as AnyNodeId + const index = target.index + const originalPath = target.fence.path ?? [] + const originalPoint = originalPath[index] ?? target.fence.start + + const [cursor, setCursor] = useState<[number, number, number]>([ + originalPoint[0], + 0, + originalPoint[1], + ]) + + useEffect(() => { + pauseSceneHistory(useScene) + let committed = false + let lastPoint: [number, number] = [originalPoint[0], originalPoint[1]] + + const buildPatch = (point: [number, number]): Partial => { + const nextPath = originalPath.map((pathPoint, pathIndex) => + pathIndex === index ? point : pathPoint, + ) + const patch: Partial = { path: nextPath } + if (index === 0) patch.start = point + if (index === nextPath.length - 1) patch.end = point + return patch + } + + const previewPath = (point: [number, number]) => { + useLiveNodeOverrides.getState().set(fenceId, buildPatch(point)) + useScene.getState().markDirty(fenceId) + } + + const restore = () => { + useLiveNodeOverrides.getState().clear(fenceId) + useScene.getState().markDirty(fenceId) + } + + const exit = (didCommit: boolean) => { + if (didCommit) triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [fenceId] }) + useInteractionScope + .getState() + .endIf( + (scope) => + scope.kind === 'reshaping' && + scope.reshape === 'control-point' && + scope.nodeId === fenceId && + scope.index === index, + ) + } + + const onGridMove = (event: GridEvent) => { + const step = isGridSnapActive() ? getSegmentGridStep() : 0 + const x = step > 0 ? snapScalarToGrid(event.localPosition[0], step) : event.localPosition[0] + const z = step > 0 ? snapScalarToGrid(event.localPosition[2], step) : event.localPosition[2] + if (x !== lastPoint[0] || z !== lastPoint[1]) { + if (step > 0) triggerSFX('sfx:grid-snap') + lastPoint = [x, z] + setCursor([x, 0, z]) + previewPath([x, z]) + } + } + + const onGridClick = (event: GridEvent) => { + committed = true + resumeSceneHistory(useScene) + useScene.getState().updateNode(fenceId, buildPatch(lastPoint)) + useLiveNodeOverrides.getState().clear(fenceId) + useScene.getState().markDirty(fenceId) + exit(true) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + restore() + resumeSceneHistory(useScene) + markToolCancelConsumed() + exit(false) + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + if (!committed) { + restore() + resumeSceneHistory(useScene) + } + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + } + }, [fenceId, index, originalPath, originalPoint, originalPoint[0], originalPoint[1]]) + + return ( + + + + ) +} + +export default MoveFenceControlPointTool diff --git a/packages/nodes/src/fence/move-tangent-tool.tsx b/packages/nodes/src/fence/move-tangent-tool.tsx new file mode 100644 index 000000000..6109dfbec --- /dev/null +++ b/packages/nodes/src/fence/move-tangent-tool.tsx @@ -0,0 +1,132 @@ +'use client' + +import { + type AnyNodeId, + emitter, + type FenceNode, + type GridEvent, + pauseSceneHistory, + resumeSceneHistory, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { + CursorSphere, + getSegmentGridStep, + isGridSnapActive, + markToolCancelConsumed, + snapScalarToGrid, + triggerSFX, + useInteractionScope, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useState } from 'react' + +const TANGENT_HANDLE_ARM_SCALE = 3 + +export const MoveFenceTangentTool: React.FC<{ + target: { fence: FenceNode; index: number; side: 'in' | 'out' } +}> = ({ target }) => { + const fenceId = target.fence.id as AnyNodeId + const { index, side } = target + const anchor = target.fence.path?.[index] ?? target.fence.start + + const [cursor, setCursor] = useState<[number, number, number]>([anchor[0], 0, anchor[1]]) + + useEffect(() => { + pauseSceneHistory(useScene) + let committed = false + const originalTangents: Array<[number, number] | null> = (target.fence.tangents ?? []).map( + (t) => (t ? [t[0], t[1]] : null), + ) + let lastTangents = originalTangents + + const writeTangent = (vector: [number, number]) => { + const pathLength = target.fence.path?.length ?? originalTangents.length + const next: Array<[number, number] | null> = Array.from( + { length: pathLength }, + (_, tangentIndex) => lastTangents[tangentIndex] ?? null, + ) + next[index] = vector + lastTangents = next + useLiveNodeOverrides.getState().set(fenceId, { tangents: next }) + useScene.getState().markDirty(fenceId) + } + + const restore = () => { + useLiveNodeOverrides.getState().clear(fenceId) + useScene.getState().markDirty(fenceId) + lastTangents = originalTangents + } + + const exit = (didCommit: boolean) => { + if (didCommit) triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [fenceId] }) + useInteractionScope + .getState() + .endIf( + (scope) => + scope.kind === 'reshaping' && + scope.reshape === 'tangent' && + scope.nodeId === fenceId && + scope.index === index && + scope.side === side, + ) + } + + const onGridMove = (event: GridEvent) => { + const step = isGridSnapActive() ? getSegmentGridStep() : 0 + const px = step > 0 ? snapScalarToGrid(event.localPosition[0], step) : event.localPosition[0] + const pz = step > 0 ? snapScalarToGrid(event.localPosition[2], step) : event.localPosition[2] + setCursor([px, 0, pz]) + let armX = px - anchor[0] + let armZ = pz - anchor[1] + if (side === 'in') { + armX = -armX + armZ = -armZ + } + writeTangent([armX / TANGENT_HANDLE_ARM_SCALE, armZ / TANGENT_HANDLE_ARM_SCALE]) + } + + const onGridClick = (event: GridEvent) => { + committed = true + const finalTangents = lastTangents + resumeSceneHistory(useScene) + useScene.getState().updateNode(fenceId, { tangents: finalTangents }) + useLiveNodeOverrides.getState().clear(fenceId) + useScene.getState().markDirty(fenceId) + lastTangents = finalTangents + exit(true) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + restore() + resumeSceneHistory(useScene) + markToolCancelConsumed() + exit(false) + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + if (!committed) { + restore() + resumeSceneHistory(useScene) + } + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + } + }, [anchor[0], anchor[1], fenceId, index, side, target.fence]) + + return ( + + + + ) +} + +export default MoveFenceTangentTool diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index 091655040..fcc1f2790 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -7,7 +7,10 @@ import { type FenceNode, type GridEvent, getPerpendicularWallMoveAxis, + isCurvedWall, + isSplineFence, type LevelNode, + useLiveNodeOverrides, useScene, type WallMoveAxis, type WallNode, @@ -15,6 +18,8 @@ import { import { CursorSphere, consumePlacementDragRelease, + getSegmentGridStep, + isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, snapFenceDraftPoint, @@ -27,10 +32,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' /** * Phase 5 Stage D — fence whole-move tool. * - * Live-drag pattern: translate the fence via direct scene updates while - * temporal history is paused. On commit we restore the original, resume - * history, apply the final position (single undo step), then re-pause. - * `constrainWallMoveDeltaToAxis` keeps moves axis-aligned. + * Live-drag pattern: preview data-driven reshapes through live node + * overrides. On commit we write the final position once for a single undo + * step. + * Straight fences use `constrainWallMoveDeltaToAxis` to keep side-moves + * perpendicular; curved fences translate freely because their path shape + * already carries direction. * * Wired via `def.affordanceTools.move`. The editor's `MoveTool` * dispatcher picks this up before its legacy chain. @@ -43,6 +50,7 @@ type LinkedFenceSnapshot = { id: FenceNode['id'] start: [number, number] end: [number, number] + path?: [number, number][] } function getLinkedFenceSnapshots(args: { @@ -70,11 +78,32 @@ function getLinkedFenceSnapshots(args: { id: node.id, start: [...node.start] as [number, number], end: [...node.end] as [number, number], + path: node.path?.map((point) => [...point] as [number, number]), }) } return snapshots } +function translatePath( + path: [number, number][] | undefined, + deltaX: number, + deltaZ: number, +): [number, number][] | undefined { + return path?.map((point) => [point[0] + deltaX, point[1] + deltaZ]) +} + +function projectLinkedPath( + path: [number, number][] | undefined, + start: [number, number], + end: [number, number], +): [number, number][] | undefined { + if (!path || path.length === 0) return path + const nextPath = path.map((point) => [...point] as [number, number]) + nextPath[0] = start + nextPath[nextPath.length - 1] = end + return nextPath +} + function getLinkedFenceUpdates( linkedFences: LinkedFenceSnapshot[], originalStart: [number, number], @@ -82,19 +111,25 @@ function getLinkedFenceUpdates( nextStart: [number, number], nextEnd: [number, number], ) { - return linkedFences.map((fence) => ({ - id: fence.id, - start: samePoint(fence.start, originalStart) + return linkedFences.map((fence) => { + const start = samePoint(fence.start, originalStart) ? nextStart : samePoint(fence.start, originalEnd) ? nextEnd - : fence.start, - end: samePoint(fence.end, originalStart) + : fence.start + const end = samePoint(fence.end, originalStart) ? nextStart : samePoint(fence.end, originalEnd) ? nextEnd - : fence.end, - })) + : fence.end + + return { + id: fence.id, + start, + end, + path: projectLinkedPath(fence.path, start, end), + } + }) } export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { @@ -102,6 +137,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const previousGridPosRef = useRef<[number, number] | null>(null) const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) + const originalPathRef = useRef(node.path?.map((point) => [...point] as [number, number])) const meta = typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) ? (node.metadata as Record) @@ -119,9 +155,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { }), ) const dragAnchorRef = useRef<[number, number] | null>(null) - const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) + const previewRef = useRef<{ + start: [number, number] + end: [number, number] + path?: [number, number][] + } | null>(null) + const canMoveFreely = isSplineFence(node) || isCurvedWall(node) const moveAxisRef = useRef( - getPerpendicularWallMoveAxis(node.start, node.end), + canMoveFreely ? null : getPerpendicularWallMoveAxis(node.start, node.end), ) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { @@ -155,12 +196,38 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { let wasCommitted = false const applyNodePreview = ( - updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>, + updates: Array<{ + id: FenceNode['id'] + start: [number, number] + end: [number, number] + path?: [number, number][] + }>, + ) => { + useLiveNodeOverrides + .getState() + .setMany( + updates.map((entry) => [ + entry.id as AnyNodeId, + { start: entry.start, end: entry.end, path: entry.path }, + ]), + ) + for (const entry of updates) { + useScene.getState().markDirty(entry.id as AnyNodeId) + } + } + + const applyCommittedUpdates = ( + updates: Array<{ + id: FenceNode['id'] + start: [number, number] + end: [number, number] + path?: [number, number][] + }>, ) => { useScene.getState().updateNodes( updates.map((entry) => ({ id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, + data: { start: entry.start, end: entry.end, path: entry.path }, })), ) for (const entry of updates) { @@ -169,19 +236,27 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { } const restoreOriginal = () => { - applyNodePreview([ - { id: fenceId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) + const overrides = useLiveNodeOverrides.getState() + overrides.clear(fenceId) + for (const linkedFence of linkedOriginalsRef.current) { + overrides.clear(linkedFence.id) + } + useScene.getState().markDirty(fenceId) + for (const linkedFence of linkedOriginalsRef.current) { + useScene.getState().markDirty(linkedFence.id as AnyNodeId) + } } const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { - previewRef.current = { start: nextStart, end: nextEnd } + const deltaX = nextStart[0] - originalStart[0] + const deltaZ = nextStart[1] - originalStart[1] + const nextPath = translatePath(originalPathRef.current, deltaX, deltaZ) + previewRef.current = { start: nextStart, end: nextEnd, path: nextPath } const centerX = (nextStart[0] + nextEnd[0]) / 2 const centerZ = (nextStart[1] + nextEnd[1]) / 2 setCursorLocalPos([centerX, 0, centerZ]) const previewUpdates = [ - { id: fenceId, start: nextStart, end: nextEnd }, + { id: fenceId, start: nextStart, end: nextEnd, path: nextPath }, ...getLinkedFenceUpdates( linkedOriginalsRef.current, originalStart, @@ -195,18 +270,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { - const bypassSnap = event.nativeEvent?.shiftKey === true + const gridSnapActive = isGridSnapActive() + const magneticSnapActive = isMagneticSnapActive() const [localX, localZ] = snapFenceDraftPoint({ point: [event.localPosition[0], event.localPosition[2]], walls: levelWalls, fences: levelFences, ignoreFenceIds: [fenceId], - bypassSnap, - magnetic: !bypassSnap && isMagneticSnapActive(), + magnetic: magneticSnapActive, + step: gridSnapActive ? getSegmentGridStep() : 0, }) if ( - !bypassSnap && + (gridSnapActive || magneticSnapActive) && previousGridPosRef.current && (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { @@ -217,11 +293,11 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const anchor = dragAnchorRef.current ?? [localX, localZ] dragAnchorRef.current = anchor - const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis( - localX - anchor[0], - localZ - anchor[1], - moveAxisRef.current, - ) + const rawDeltaX = localX - anchor[0] + const rawDeltaZ = localZ - anchor[1] + const [deltaX, deltaZ] = canMoveFreely + ? [rawDeltaX, rawDeltaZ] + : constrainWallMoveDeltaToAxis(rawDeltaX, rawDeltaZ, moveAxisRef.current) const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ] const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ] @@ -245,13 +321,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { return } - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - restoreOriginal() - useScene.temporal.getState().resume() - applyNodePreview([ - { id: fenceId, start: preview.start, end: preview.end }, + applyCommittedUpdates([ + { id: fenceId, start: preview.start, end: preview.end, path: preview.path }, ...getLinkedFenceUpdates( linkedOriginalsRef.current, originalStart, @@ -260,6 +332,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { preview.end, ), ]) + restoreOriginal() useScene.temporal.getState().pause() triggerSFX('sfx:item-place') @@ -316,7 +389,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) } - }, [exitMoveMode, node]) + }, [exitMoveMode, node, canMoveFreely]) return ( diff --git a/packages/nodes/src/fence/parametrics.ts b/packages/nodes/src/fence/parametrics.ts index 59f3bcd2b..8d5f67a30 100644 --- a/packages/nodes/src/fence/parametrics.ts +++ b/packages/nodes/src/fence/parametrics.ts @@ -1,4 +1,4 @@ -import type { ParametricDescriptor } from '@pascal-app/core' +import { isSplineFence, type ParametricDescriptor } from '@pascal-app/core' import { FenceCurveEditor, FenceLengthEditor } from './inspector-editors' import type { FenceNode } from './schema' @@ -37,8 +37,20 @@ export const fenceParametrics: ParametricDescriptor = { { label: 'Dimensions', fields: [ - { key: 'length', kind: 'custom', component: FenceLengthEditor }, - { key: 'curve', kind: 'custom', component: FenceCurveEditor }, + // Length / Curve drive start/end + the single sagitta — meaningless + // for a multi-point spline fence, so hide them when `path` is set. + { + key: 'length', + kind: 'custom', + component: FenceLengthEditor, + visibleIf: (n) => !isSplineFence(n), + }, + { + key: 'curve', + kind: 'custom', + component: FenceCurveEditor, + visibleIf: (n) => !isSplineFence(n), + }, { key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 4, step: 0.05 }, { key: 'thickness', kind: 'number', unit: 'm', min: 0.03, max: 0.5, step: 0.005 }, ], diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 1909bf4ce..ac348b9dc 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -6,10 +6,12 @@ import { emitter, type FenceNode, type GridEvent, + getTwoPointFenceCurveTangents, getWallMiterBoundaryPoints, type LevelNode, type Point2D, resolveAlignment, + sampleFenceSpline, useScene, type WallMiterData, type WallNode, @@ -17,6 +19,7 @@ import { import { CursorSphere, createFenceOnCurrentLevel, + createSplineFenceOnCurrentLevel, EDITOR_LAYER, type FencePlanPoint, formatAngleRadians, @@ -24,14 +27,18 @@ import { getAngleArcToSegmentReference, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + getSegmentGridStep, isAngleSnapActive, + isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, type SegmentAngleReference, snapFenceDraftPoint, + snapScalarToGrid, triggerSFX, useAlignmentGuides, useEditor, + useFenceCurveDraft, useSegmentDraftChain, } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' @@ -418,7 +425,7 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } { const { nodes } = useScene.getState() if (!currentLevelId) return { walls: [], fences: [] } const levelNode = nodes[currentLevelId] - if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] } + if (levelNode?.type !== 'level') return { walls: [], fences: [] } const children = (levelNode as LevelNode).children.map((childId) => nodes[childId]) return { walls: children.filter((n): n is WallNode => n?.type === 'wall'), @@ -427,6 +434,14 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } { } export const FenceTool: React.FC = () => { + const fenceMode = useEditor((s) => s.continuationByContext.fence) + if (fenceMode === 'curved') { + return + } + return +} + +const StraightFenceTool: React.FC = () => { const unit = useViewer((state) => state.unit) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') // A placed preset seeds `toolDefaults.fence` before the tool mounts, so @@ -693,6 +708,132 @@ export const FenceTool: React.FC = () => { ) } +const SPLINE_PREVIEW_COLOR = '#8381ed' +const SPLINE_PREVIEW_SEGMENTS = 40 + +const SplineFenceDraft: React.FC = () => { + const previewHeight = + typeof useEditor.getState().toolDefaults.fence?.height === 'number' + ? (useEditor.getState().toolDefaults.fence?.height as number) + : FENCE_PREVIEW_HEIGHT + const [draftPoints, setDraftPoints] = useState([]) + const [cursor, setCursor] = useState(null) + const draftRef = useRef(draftPoints) + + draftRef.current = draftPoints + + // Mirror the draft length into the HUD store so the "finish curve" hint only + // shows once drafting has started; always clear it when the tool unmounts. + useEffect(() => { + useFenceCurveDraft.getState().setPointCount(draftPoints.length) + }, [draftPoints]) + useEffect(() => () => useFenceCurveDraft.getState().reset(), []) + + useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), []) + + useEffect(() => { + const snapPoint = (local: FencePlanPoint): FencePlanPoint => { + const step = isGridSnapActive() ? getSegmentGridStep() : 0 + if (step <= 0) return local + return [snapScalarToGrid(local[0], step), snapScalarToGrid(local[1], step)] + } + + const commit = () => { + const points = draftRef.current + if (points.length >= 2) { + const created = createSplineFenceOnCurrentLevel(points) + if (created) { + triggerSFX('sfx:item-place') + // Once the new curve fence is selected for direct editing, leave + // placement mode so the toolbar matches the active interaction. + useViewer.getState().setSelection({ selectedIds: [created.id] }) + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + } + } + setDraftPoints([]) + setCursor(null) + } + + const onMove = (event: GridEvent) => { + setCursor(snapPoint([event.localPosition[0], event.localPosition[2]])) + } + + const onClick = (event: GridEvent) => { + if (event.nativeEvent.detail >= 2) { + commit() + return + } + const point = snapPoint([event.localPosition[0], event.localPosition[2]]) + triggerSFX('sfx:grid-snap') + setDraftPoints((prev) => [...prev, point]) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') commit() + } + const onCancel = () => { + if (draftRef.current.length === 0) return + markToolCancelConsumed() + setDraftPoints((prev) => prev.slice(0, -1)) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + } + }, []) + + const previewPoints = cursor ? [...draftPoints, cursor] : draftPoints + const curveGeometry = useMemo(() => { + if (previewPoints.length < 2) return null + const sampled = sampleFenceSpline( + previewPoints, + getTwoPointFenceCurveTangents(previewPoints), + SPLINE_PREVIEW_SEGMENTS, + ) + return new BufferGeometry().setFromPoints( + sampled.map((point) => new Vector3(point.x, previewHeight, point.y)), + ) + }, [previewHeight, previewPoints]) + + return ( + + {cursor && } + {draftPoints.map((point, index) => ( + + + + + ))} + {curveGeometry && ( + // @ts-expect-error - R3F accepts Three line primitives here. + + + + )} + + ) +} + function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { const geometry = useMemo(() => { const segmentCount = Math.max( diff --git a/packages/nodes/src/gutter/downspouts-panel.tsx b/packages/nodes/src/gutter/downspouts-panel.tsx index 06cbafd02..640312f6f 100644 --- a/packages/nodes/src/gutter/downspouts-panel.tsx +++ b/packages/nodes/src/gutter/downspouts-panel.tsx @@ -76,7 +76,7 @@ export default function DownspoutsPanel() { }), ) - if (!gutter || gutter.type !== 'gutter') return null + if (gutter?.type !== 'gutter') return null const handleSelectDownspout = (id: AnyNodeId) => { setSelection({ selectedIds: [id] }) diff --git a/packages/nodes/src/gutter/floorplan.ts b/packages/nodes/src/gutter/floorplan.ts index e292a906c..d032def70 100644 --- a/packages/nodes/src/gutter/floorplan.ts +++ b/packages/nodes/src/gutter/floorplan.ts @@ -31,10 +31,10 @@ export function buildGutterFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null // Compose roof → segment → gutter in plan coords. Each rotation is // negated so SVG's y-down CW matches Three.js' top-down CCW — the same @@ -111,7 +111,7 @@ export function buildGutterFloorplan( const mitreSiblings: GutterWithSegment[] = [] for (const segId of roof.children ?? []) { const sib = ctx.resolve(segId as AnyNodeId) as RoofSegmentNode | undefined - if (!sib || sib.type !== 'roof-segment') continue + if (sib?.type !== 'roof-segment') continue for (const gid of sib.children ?? []) { const g = ctx.resolve(gid as AnyNodeId) as GutterNode | undefined if (g && g.type === 'gutter' && g.id !== node.id) { diff --git a/packages/nodes/src/gutter/length-snap.ts b/packages/nodes/src/gutter/length-snap.ts index 95177a8d7..b3c381bb7 100644 --- a/packages/nodes/src/gutter/length-snap.ts +++ b/packages/nodes/src/gutter/length-snap.ts @@ -104,7 +104,7 @@ export function snapLengthToCorner( const roofChildren = (roof as { children?: readonly string[] } | undefined)?.children for (const sid of roofChildren ?? []) { const s = sceneApi.get(sid as AnyNodeId) - if (!s || s.type !== 'roof-segment') continue + if (s?.type !== 'roof-segment') continue const tf = segmentTransform(s) for (const gid of s.children ?? []) { const g = sceneApi.get(gid as AnyNodeId) diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index 5a3862d66..b9adcde28 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -19,6 +19,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner-mitre' import { computeSharedEaveY } from './eave-align' import { computeEaveY } from './eave-snap' @@ -200,6 +201,22 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + // Map gutter-local geometry into the host segment's local frame (where the + // trim cut prisms live) — same pose the inner mesh group is mounted with + // (position [x, liveEaveY, z] + yaw). Computed before the early return so the + // hook order stays stable. + const liveEaveYForClip = sharedEaveY ?? (effectiveSegment ? computeEaveY(effectiveSegment) : 0) + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, liveEaveYForClip, node.position[2] ?? 0), + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), node.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[2], node.rotation, liveEaveYForClip], + ) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment) + if (!segment || !effectiveSegment) return null // `node.position` is segment-local — the placement tool resolves the @@ -232,7 +249,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { > { if (state.readOnly) return state const currentNode = state.nodes[nodeId] - if (!currentNode || currentNode.type !== 'item') return state + if (currentNode?.type !== 'item') return state return { materials: { ...state.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial }, nodes: { diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx index c20fd6ed4..25c2c20c4 100644 --- a/packages/nodes/src/liquid-line/tool.tsx +++ b/packages/nodes/src/liquid-line/tool.tsx @@ -226,7 +226,7 @@ function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null const scene = useScene.getState() const linesets: LinesetNode[] = [] for (const n of Object.values(scene.nodes)) { - if (!n || n.type !== 'lineset') continue + if (n?.type !== 'lineset') continue if ((n.parentId as AnyNodeId | null) !== levelId) continue const ls = n as LinesetNode if (ls.path.length >= 2) linesets.push(ls) diff --git a/packages/nodes/src/pipe-trap/definition.ts b/packages/nodes/src/pipe-trap/definition.ts index 490bd3946..a2d051ec5 100644 --- a/packages/nodes/src/pipe-trap/definition.ts +++ b/packages/nodes/src/pipe-trap/definition.ts @@ -59,7 +59,7 @@ export const pipeTrapDefinition: NodeDefinition = { presentation: { label: 'Trap', description: 'DWV P-trap — water seal on the waste line. The trap arm runs to the vent.', - icon: { kind: 'iconify', name: 'lucide:spline' }, + icon: { kind: 'url', src: '/icons/dwv-pipes.webp' }, paletteSection: 'structure', paletteOrder: 98, }, diff --git a/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts b/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts index 4c0ec9514..3a2e99257 100644 --- a/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts @@ -1,7 +1,65 @@ import { describe, expect, test } from 'bun:test' +import { getDutchRoofMetrics, getRidgeVentLinesForSegment, RoofSegmentNode } from '@pascal-app/core' +import type * as THREE from 'three' +import { getRoofTopSurfaceY } from '../../shared/roof-surface' import { buildRidgeVentGeometry } from '../geometry' import { RidgeVentNode } from '../schema' +function minYAtLocalPoint(geo: THREE.BufferGeometry, targetX: number, targetZ: number): number { + const pos = geo.getAttribute('position').array as Float32Array + let minY = Infinity + for (let i = 0; i < pos.length; i += 3) { + if (Math.abs(pos[i]! - targetX) <= 1e-5 && Math.abs(pos[i + 2]! - targetZ) <= 1e-5) { + minY = Math.min(minY, pos[i + 1]!) + } + } + return minY +} + +function xBounds(geo: THREE.BufferGeometry): { minX: number; maxX: number } { + const pos = geo.getAttribute('position').array as Float32Array + let minX = Infinity + let maxX = -Infinity + for (let i = 0; i < pos.length; i += 3) { + minX = Math.min(minX, pos[i]!) + maxX = Math.max(maxX, pos[i]!) + } + return { minX, maxX } +} + +function maxAbsZNearX(geo: THREE.BufferGeometry, targetX: number, tolerance = 0.03): number { + const pos = geo.getAttribute('position').array as Float32Array + let maxAbsZ = 0 + for (let i = 0; i < pos.length; i += 3) { + if (Math.abs(pos[i]! - targetX) <= tolerance) { + maxAbsZ = Math.max(maxAbsZ, Math.abs(pos[i + 2]!)) + } + } + return maxAbsZ +} + +function expectFinitePositions(geo: THREE.BufferGeometry): void { + const pos = geo.getAttribute('position').array as Float32Array + for (let i = 0; i < pos.length; i++) { + expect(Number.isFinite(pos[i])).toBe(true) + } +} + +function rotatedSurfaceYAt( + segment: RoofSegmentNode, + centerX: number, + centerZ: number, + rotation: number, + localX: number, + localZ: number, +): number { + return getRoofTopSurfaceY( + centerX + localX * Math.cos(rotation) + localZ * Math.sin(rotation), + centerZ - localX * Math.sin(rotation) + localZ * Math.cos(rotation), + segment, + ) +} + describe('buildRidgeVentGeometry', () => { test('returns geometry with matching position / normal / uv counts', () => { const geo = buildRidgeVentGeometry(RidgeVentNode.parse({})) @@ -38,14 +96,282 @@ describe('buildRidgeVentGeometry', () => { test('length scales the X bounds proportionally', () => { const geo = buildRidgeVentGeometry(RidgeVentNode.parse({ length: 4, endCaps: false })) - const pos = geo.getAttribute('position').array as Float32Array - let maxX = -Infinity - let minX = Infinity - for (let i = 0; i < pos.length; i += 3) { - if (pos[i]! > maxX) maxX = pos[i]! - if (pos[i]! < minX) minX = pos[i]! - } + const { minX, maxX } = xBounds(geo) expect(maxX).toBeCloseTo(2) expect(minX).toBeCloseTo(-2) }) + + test('legacy partial ridge vents never produce NaN positions', () => { + const geo = buildRidgeVentGeometry({ + id: 'rvent_legacy', + type: 'ridge-vent', + } as unknown as Parameters[0]) + + expect(geo.getAttribute('position').count).toBeGreaterThan(0) + expectFinitePositions(geo) + }) + + test('clips rendered length to host segment trim without mutating the stored length', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { left: 2, right: 1 }, + }) + const vent = RidgeVentNode.parse({ + length: 8, + position: [0, 0, 0], + rotation: 0, + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const { minX, maxX } = xBounds(geo) + + expect(vent.length).toBe(8) + expect(minX).toBeCloseTo(-2) + expect(maxX).toBeCloseTo(3) + }) + + test('clips rendered ridge vents against diagonal trim planes', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { frontLeftX: 2, frontLeftZ: 4 }, + }) + const vent = RidgeVentNode.parse({ + length: 8, + width: 0.3, + position: [0, 0, 0], + rotation: 0, + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const { minX, maxX } = xBounds(geo) + + expect(vent.length).toBe(8) + expect(vent.position[0]).toBe(0) + expect(minX).toBeCloseTo(-3.575, 3) + expect(maxX).toBeCloseTo(4) + }) + + test('seats the underside onto rendered roof top faces when a segment is provided', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const vent = RidgeVentNode.parse({ + width: 0.4, + height: 0.12, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const halfWidth = vent.width / 2 + const ridgeY = getRoofTopSurfaceY(0, 0, segment) + + expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo( + getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY, + ) + expect(minYAtLocalPoint(geo, -halfLength, halfWidth)).toBeCloseTo( + getRoofTopSurfaceY(-halfLength, halfWidth, segment) - ridgeY, + ) + }) + + test('seats the underside using the vent rotation for diagonal hip caps', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'hip', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const rotation = Math.PI / 4 + const centerX = -2.5 + const centerZ = 1.5 + const vent = RidgeVentNode.parse({ + position: [centerX, 0, centerZ], + rotation, + width: 0.4, + height: 0.12, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const startHalfWidth = maxAbsZNearX(geo, -halfLength, 1e-5) + const ridgeY = rotatedSurfaceYAt(segment, centerX, centerZ, rotation, 0, 0) + + expect(startHalfWidth).toBeGreaterThan(0) + expect(minYAtLocalPoint(geo, -halfLength, -startHalfWidth)).toBeCloseTo( + rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, -startHalfWidth) - ridgeY, + ) + expect(minYAtLocalPoint(geo, -halfLength, startHalfWidth)).toBeCloseTo( + rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, startHalfWidth) - ridgeY, + ) + }) + + test('seats diagonal hip cap ends at different roof heights along the slope', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'hip', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const rotation = Math.PI / 4 + const centerX = -2.5 + const centerZ = 1.5 + const vent = RidgeVentNode.parse({ + position: [centerX, 0, centerZ], + rotation, + length: Math.SQRT2, + width: 0.4, + height: 0.12, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const startHalfWidth = maxAbsZNearX(geo, -halfLength, 1e-5) + const endHalfWidth = maxAbsZNearX(geo, halfLength, 1e-5) + const ridgeY = rotatedSurfaceYAt(segment, centerX, centerZ, rotation, 0, 0) + const leftEndY = + rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, -startHalfWidth) - ridgeY + const rightEndY = + rotatedSurfaceYAt(segment, centerX, centerZ, rotation, halfLength, -endHalfWidth) - ridgeY + + expect(startHalfWidth).toBeGreaterThan(0) + expect(endHalfWidth).toBeGreaterThan(0) + expect(leftEndY).not.toBeCloseTo(rightEndY) + expect(minYAtLocalPoint(geo, -halfLength, -startHalfWidth)).toBeCloseTo(leftEndY) + expect(minYAtLocalPoint(geo, halfLength, -endHalfWidth)).toBeCloseTo(rightEndY) + }) + + test('seats the dutch top ridge with the same sloped cap profile as other ridge vents', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const vent = RidgeVentNode.parse({ + name: 'Ridge Vent', + position: [0, 0, 0], + rotation: 0, + length: 5, + width: 0.3, + height: 0.1, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const halfWidth = vent.width / 2 + const ridgeY = getRoofTopSurfaceY(0, 0, segment) + const rawRoofDrop = getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY + + expect(rawRoofDrop).toBeLessThan(-0.05) + expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(rawRoofDrop) + }) + + test('keeps an extended Dutch top ridge level through the rake span instead of drooping onto the hip', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const metrics = getDutchRoofMetrics(segment) + const rakeReach = Math.min( + segment.dutchGabletRake, + Math.max(0, segment.width / 2 - metrics.waistHalfX) * 0.98, + ) + const vent = RidgeVentNode.parse({ + name: 'Ridge Vent', + position: [0, 0, 0], + rotation: 0, + length: (metrics.waistHalfX + rakeReach) * 2, + width: 0.3, + height: 0.1, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const halfWidth = vent.width / 2 + const ridgeY = getRoofTopSurfaceY(0, 0, segment) + const rawRoofDrop = getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY + const supportedDrop = getRoofTopSurfaceY(-metrics.waistHalfX, -halfWidth, segment) - ridgeY + + expect(rawRoofDrop).toBeLessThan(supportedDrop - 0.05) + expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(supportedDrop) + expect(minYAtLocalPoint(geo, halfLength, -halfWidth)).toBeCloseTo(supportedDrop) + }) + + test('tapers Dutch hip ridge vent ends to a small capped nose without shortening the support-line length', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + const hipLine = getRidgeVentLinesForSegment(segment).find( + (line) => line.name === 'Hip Ridge Vent' && line.start[0] < 0 && line.start[1] < 0, + ) + expect(hipLine).toBeDefined() + const line = hipLine! + const rotation = Math.atan2(-(line.end[1] - line.start[1]), line.end[0] - line.start[0]) + const vent = RidgeVentNode.parse({ + name: 'Hip Ridge Vent', + position: [(line.start[0] + line.end[0]) / 2, 0, (line.start[1] + line.end[1]) / 2], + rotation, + length: Math.hypot(line.end[0] - line.start[0], line.end[1] - line.start[1]), + width: 0.3, + height: 0.1, + style: 'shingled', + endCaps: true, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const bounds = xBounds(geo) + const fullHalfLength = vent.length / 2 + const startWidth = maxAbsZNearX(geo, bounds.minX) + const endWidth = maxAbsZNearX(geo, bounds.maxX) + const bodyWidth = maxAbsZNearX(geo, 0, 0.08) + + expect(bounds.minX).toBeLessThanOrEqual(-fullHalfLength + 0.02) + expect(bounds.maxX).toBeGreaterThan(fullHalfLength - 0.15) + expect(startWidth).toBeGreaterThan(0.03) + expect(startWidth).toBeLessThan(0.09) + expect(endWidth).toBeGreaterThan(0.1) + expect(bodyWidth).toBeGreaterThan(startWidth + 0.015) + }) }) diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index 9d11c8632..949b187fc 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -152,8 +152,8 @@ const ridgeVentHandles: HandleDescriptor[] = [ * geometry builder shared with the placement preview + future tests, * no animation or per-frame system. * - * The placement tool snaps to the ridge (segment-local Z=0) wherever - * the cursor lands on a segment. + * The placement tool snaps to the nearest ridge/break line wherever the + * cursor lands on a segment. */ export const ridgeVentDefinition: NodeDefinition = { kind: 'ridge-vent', diff --git a/packages/nodes/src/ridge-vent/floorplan.ts b/packages/nodes/src/ridge-vent/floorplan.ts index 793db2217..3261b7fd3 100644 --- a/packages/nodes/src/ridge-vent/floorplan.ts +++ b/packages/nodes/src/ridge-vent/floorplan.ts @@ -29,10 +29,10 @@ export function buildRidgeVentFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null const cosR = Math.cos(-roof.rotation) const sinR = Math.sin(-roof.rotation) diff --git a/packages/nodes/src/ridge-vent/geometry.ts b/packages/nodes/src/ridge-vent/geometry.ts index b97eb6bdd..8984a5ea3 100644 --- a/packages/nodes/src/ridge-vent/geometry.ts +++ b/packages/nodes/src/ridge-vent/geometry.ts @@ -1,8 +1,46 @@ -import type { RidgeVentNode } from '@pascal-app/core' +import { + getDutchRoofMetrics, + getRidgeVentLinesForSegment, + normalizeRoofSegmentTrim, + type RidgeVentNode, + type RoofSegmentNode, +} from '@pascal-app/core' import * as THREE from 'three' +import { getRoofTopSurfaceY } from '../shared/roof-surface' const ARC_SEGS = 16 const SHINGLED_TAB_SIZE = 0.3 +const DEFAULT_RIDGE_VENT_LENGTH = 2 +const DEFAULT_RIDGE_VENT_WIDTH = 0.3 +const DEFAULT_RIDGE_VENT_HEIGHT = 0.1 +type ProfilePoint = [z: number, capY: number] +type RidgeVentGeometryVertex = { + x: number + y: number + z: number + nx: number + ny: number + nz: number + u: number + v: number +} +type SegmentTrimClipPlane = { + signedDistance: (segmentX: number, segmentZ: number) => number +} + +type RidgeVentSupportLine = { + startX: number + endX: number + name: string + taperAtStart: boolean + taperAtEnd: boolean +} +type RidgeVentEndTaper = { + taperAtStart: boolean + taperAtEnd: boolean + taperLength: number + tipHalfWidth: number +} /** * Pure builder for the ridge vent mesh. Each style is a peaked **band** of @@ -22,32 +60,136 @@ const SHINGLED_TAB_SIZE = 0.3 * * `endCaps` closes both ends. Pure: no React, no scene access, no mutation. */ -export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometry { - const halfLen = node.length / 2 - const halfW = node.width / 2 - const h = node.height +export function buildRidgeVentGeometry( + node: RidgeVentNode, + segment?: RoofSegmentNode, +): THREE.BufferGeometry { + const length = finitePositive(node.length, DEFAULT_RIDGE_VENT_LENGTH) + const width = finitePositive(node.width, DEFAULT_RIDGE_VENT_WIDTH) + const h = finitePositive(node.height, DEFAULT_RIDGE_VENT_HEIGHT) + const halfLen = length / 2 + const halfW = width / 2 // Band thickness. Generous enough to read as a solid cap; the eave faces // are `t` tall, which is the depth the user actually sees from the side. const t = Math.max(0.02, h * 0.4) + const centerX = finiteNumber(node.position?.[0], 0) + const centerZ = finiteNumber(node.position?.[2], 0) + const rotationY = finiteNumber(node.rotation, 0) + const sinR = Math.sin(rotationY) + const cosR = Math.cos(rotationY) + const dutchTopRidgeSupport = getDutchTopRidgeSupport(segment, centerX, centerZ, rotationY) + const surfaceYAt = (x: number, z: number) => { + if (!segment) return 0 + let sampleX = centerX + x * cosR + z * sinR + let sampleZ = centerZ - x * sinR + z * cosR + if (dutchTopRidgeSupport) { + if (dutchTopRidgeSupport.axis === 'x') { + sampleX = clamp( + sampleX, + -dutchTopRidgeSupport.innerHalfSpan, + dutchTopRidgeSupport.innerHalfSpan, + ) + } else { + sampleZ = clamp( + sampleZ, + -dutchTopRidgeSupport.innerHalfSpan, + dutchTopRidgeSupport.innerHalfSpan, + ) + } + } + return getRoofTopSurfaceY(sampleX, sampleZ, segment) + } + const ridgeY = surfaceYAt(0, 0) + const seatYAt = (x: number, z: number) => (segment ? surfaceYAt(x, z) - ridgeY : 0) + const top = node.style === 'metal' ? metalTop(halfW, h, t) : node.style === 'shingled' ? shingledTop(halfW, h, t) : standardTop(halfW, h, t) + const supportLine = segment ? getSupportLineForVent(segment, node) : null + const endTaper = getRidgeVentEndTaper(supportLine, width, h) const positions: number[] = [] const normals: number[] = [] const uvs: number[] = [] - buildBand(positions, normals, uvs, top, t, halfLen, node.endCaps) + buildBand(positions, normals, uvs, top, seatYAt, -halfLen, halfLen, node.endCaps, endTaper) if (node.style === 'shingled') { - addShingledTabs(positions, normals, uvs, halfLen, top, h) + addShingledTabs(positions, normals, uvs, -halfLen, halfLen, top, h, seatYAt, endTaper) } - return buildBufferGeometry(positions, normals, uvs) + const geometry = buildBufferGeometry(positions, normals, uvs) + if (!segment) return geometry + + const clipped = clipRidgeVentGeometryToSegmentTrim(geometry, node, segment) + if (clipped !== geometry) geometry.dispose() + return clipped +} + +function finiteNumber(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function finitePositive(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function getRidgeVentEndTaper( + supportLine: RidgeVentSupportLine | null, + width: number, + height: number, +): RidgeVentEndTaper | null { + if ( + !supportLine || + (supportLine.name !== 'Hip Ridge Vent' && supportLine.name !== 'Slope Ridge Vent') + ) { + return null + } + + const lineLength = supportLine.endX - supportLine.startX + const taperLength = Math.min( + Math.max(0.07, width * 0.45, height * 0.9), + Math.max(0, lineLength / 2 - 0.02), + ) + if (!(taperLength > 0.001)) return null + + return { + taperAtStart: supportLine.taperAtStart, + taperAtEnd: supportLine.taperAtEnd, + taperLength, + tipHalfWidth: Math.min(width * 0.34, Math.max(0.04, width * 0.26)), + } +} + +function getDutchTopRidgeSupport( + segment: RoofSegmentNode | undefined, + centerX: number, + centerZ: number, + rotationY: number, +): { axis: 'x' | 'z'; innerHalfSpan: number } | null { + if (segment?.roofType !== 'dutch') return null + + const metrics = getDutchRoofMetrics(segment) + const onWidthAxisTopRidge = + metrics.axis === 'x' && Math.abs(centerZ) <= 1e-4 && Math.abs(Math.sin(rotationY)) <= 1e-4 + const onDepthAxisTopRidge = + metrics.axis === 'z' && Math.abs(centerX) <= 1e-4 && Math.abs(Math.cos(rotationY)) <= 1e-4 + + if (onWidthAxisTopRidge) { + return { axis: 'x', innerHalfSpan: metrics.waistHalfX } + } + if (onDepthAxisTopRidge) { + return { axis: 'z', innerHalfSpan: metrics.waistHalfZ } + } + return null } // ─── Top profiles (open polylines eave → peak → eave, in [z, y]) ───────── @@ -55,8 +197,8 @@ export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometr // eaves, seating the cap on the roof while leaving a peaked void beneath. // Smooth rounded arch. -function standardTop(halfW: number, h: number, t: number): [number, number][] { - const pts: [number, number][] = [] +function standardTop(halfW: number, h: number, t: number): ProfilePoint[] { + const pts: ProfilePoint[] = [] for (let i = 0; i <= ARC_SEGS; i++) { const frac = i / ARC_SEGS const z = -halfW + frac * 2 * halfW @@ -67,7 +209,7 @@ function standardTop(halfW: number, h: number, t: number): [number, number][] { } // Angular peak with a narrow flat ridge at the top. -function shingledTop(halfW: number, h: number, t: number): [number, number][] { +function shingledTop(halfW: number, h: number, t: number): ProfilePoint[] { const peakHalf = halfW * 0.12 return [ [-halfW, t], @@ -78,7 +220,7 @@ function shingledTop(halfW: number, h: number, t: number): [number, number][] { } // Bent-metal cap: steep folds up to a wide flat standing seam. -function metalTop(halfW: number, h: number, t: number): [number, number][] { +function metalTop(halfW: number, h: number, t: number): ProfilePoint[] { const seamHalf = halfW * 0.5 const shoulderY = t + (h - t) * 0.5 return [ @@ -97,78 +239,97 @@ function buildBand( positions: number[], normals: number[], uvs: number[], - top: [number, number][], - t: number, - halfLen: number, + top: ProfilePoint[], + seatYAt: (x: number, z: number) => number, + startX: number, + endX: number, withCaps: boolean, + endTaper: RidgeVentEndTaper | null = null, ): void { const n = top.length - // Underside: the same profile dropped straight down by `t` (eaves → y 0). - const inner: [number, number][] = top.map(([z, y]) => [z, y - t]) + const halfWidth = getProfileHalfWidth(top) + const stations = getRidgeVentSweepStations(startX, endX, endTaper) + const scaledZAt = (x: number, z: number): number => + z * getProfileScaleAtX(x, startX, endX, halfWidth, endTaper) + const seatAt = (x: number, z: number): number => seatYAt(x, scaledZAt(x, z)) + const topAt = (x: number, z: number, capY: number): number => seatAt(x, z) + capY // Top surface + underside, swept along the ridge length. - for (let i = 0; i < n - 1; i++) { - const [z0, y0] = top[i]! - const [z1, y1] = top[i + 1]! - pushQuad( - positions, - normals, - uvs, - [-halfLen, y0, z0], - [halfLen, y0, z0], - [halfLen, y1, z1], - [-halfLen, y1, z1], - [0, 1, 0], - ) - const [iz0, iy0] = inner[i]! - const [iz1, iy1] = inner[i + 1]! - pushQuad( - positions, - normals, - uvs, - [-halfLen, iy0, iz0], - [halfLen, iy0, iz0], - [halfLen, iy1, iz1], - [-halfLen, iy1, iz1], - [0, -1, 0], - ) + for (let station = 0; station < stations.length - 1; station += 1) { + const x0 = stations[station]! + const x1 = stations[station + 1]! + for (let i = 0; i < n - 1; i++) { + const [z0, capY0] = top[i]! + const [z1, capY1] = top[i + 1]! + const x0z0 = scaledZAt(x0, z0) + const x1z0 = scaledZAt(x1, z0) + const x1z1 = scaledZAt(x1, z1) + const x0z1 = scaledZAt(x0, z1) + pushQuad( + positions, + normals, + uvs, + [x0, topAt(x0, z0, capY0), x0z0], + [x1, topAt(x1, z0, capY0), x1z0], + [x1, topAt(x1, z1, capY1), x1z1], + [x0, topAt(x0, z1, capY1), x0z1], + [0, 1, 0], + ) + pushQuad( + positions, + normals, + uvs, + [x0, seatAt(x0, z0), x0z0], + [x1, seatAt(x1, z0), x1z0], + [x1, seatAt(x1, z1), x1z1], + [x0, seatAt(x0, z1), x0z1], + [0, -1, 0], + ) + } } // Eave thickness faces (the visible depth along each long edge). - for (const idx of [0, n - 1]) { - const [z, yTop] = top[idx]! - const [, yInner] = inner[idx]! - const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1] - pushQuad( - positions, - normals, - uvs, - [-halfLen, yInner, z], - [halfLen, yInner, z], - [halfLen, yTop, z], - [-halfLen, yTop, z], - hint, - ) + for (let station = 0; station < stations.length - 1; station += 1) { + const x0 = stations[station]! + const x1 = stations[station + 1]! + for (const idx of [0, n - 1]) { + const [z, capY] = top[idx]! + const x0z = scaledZAt(x0, z) + const x1z = scaledZAt(x1, z) + const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1] + pushQuad( + positions, + normals, + uvs, + [x0, seatAt(x0, z), x0z], + [x1, seatAt(x1, z), x1z], + [x1, topAt(x1, z, capY), x1z], + [x0, topAt(x0, z, capY), x0z], + hint, + ) + } } // End caps: the band's cross-section ring at each end. if (withCaps) { - for (const sign of [-1, 1] as const) { - const x = sign * halfLen + for (const [x, sign] of [ + [startX, -1], + [endX, 1], + ] as const) { const hint: [number, number, number] = [sign, 0, 0] for (let i = 0; i < n - 1; i++) { - const [z0, y0] = top[i]! - const [z1, y1] = top[i + 1]! - const [iz0, iy0] = inner[i]! - const [iz1, iy1] = inner[i + 1]! + const [z0, capY0] = top[i]! + const [z1, capY1] = top[i + 1]! + const scaledZ0 = scaledZAt(x, z0) + const scaledZ1 = scaledZAt(x, z1) pushQuad( positions, normals, uvs, - [x, y0, z0], - [x, y1, z1], - [x, iy1, iz1], - [x, iy0, iz0], + [x, topAt(x, z0, capY0), scaledZ0], + [x, topAt(x, z1, capY1), scaledZ1], + [x, seatAt(x, z1), scaledZ1], + [x, seatAt(x, z0), scaledZ0], hint, ) } @@ -176,6 +337,45 @@ function buildBand( } } +function getProfileHalfWidth(top: ProfilePoint[]): number { + return top.reduce((halfWidth, [z]) => Math.max(halfWidth, Math.abs(z)), 0) +} + +function getRidgeVentSweepStations( + startX: number, + endX: number, + endTaper: RidgeVentEndTaper | null, +): number[] { + const stations = [startX, endX] + if (endTaper?.taperAtStart) stations.push(startX + endTaper.taperLength) + if (endTaper?.taperAtEnd) stations.push(endX - endTaper.taperLength) + return stations + .filter((x) => x >= startX && x <= endX) + .sort((a, b) => a - b) + .filter((x, index, sorted) => index === 0 || Math.abs(x - sorted[index - 1]!) > 1e-5) +} + +function getProfileScaleAtX( + x: number, + startX: number, + endX: number, + halfWidth: number, + endTaper: RidgeVentEndTaper | null, +): number { + if (!endTaper || !(halfWidth > 0.0001)) return 1 + + const tipScale = clamp(endTaper.tipHalfWidth / halfWidth, 0, 1) + if (endTaper.taperAtStart && x <= startX + endTaper.taperLength) { + const progress = clamp((x - startX) / endTaper.taperLength, 0, 1) + return lerp(tipScale, 1, progress) + } + if (endTaper.taperAtEnd && x >= endX - endTaper.taperLength) { + const progress = clamp((endX - x) / endTaper.taperLength, 0, 1) + return lerp(tipScale, 1, progress) + } + return 1 +} + // ─── Shingled course ridges ────────────────────────────────────────────── // Thin raised lines running across the cap at intervals, suggesting // overlapping shingle courses. Sit on the top profile edges. @@ -184,21 +384,27 @@ function addShingledTabs( positions: number[], normals: number[], uvs: number[], - halfLen: number, - top: [number, number][], + startX: number, + endX: number, + top: ProfilePoint[], h: number, + seatYAt: (x: number, z: number) => number, + endTaper: RidgeVentEndTaper | null = null, ): void { - const totalLen = halfLen * 2 + const totalLen = endX - startX const numTabs = Math.max(2, Math.round(totalLen / SHINGLED_TAB_SIZE)) const tabLen = totalLen / numTabs const ridgeH = h * 0.06 const ridgeD = Math.min(0.01, tabLen * 0.15) for (let tab = 1; tab < numTabs; tab++) { - const x = -halfLen + tab * tabLen + const x = startX + tab * tabLen + if (isInsideEndTaper(x, startX, endX, endTaper)) continue for (let i = 0; i < top.length - 1; i++) { - const [z0, y0] = top[i]! - const [z1, y1] = top[i + 1]! + const [z0, capY0] = top[i]! + const [z1, capY1] = top[i + 1]! + const y0 = seatYAt(x, z0) + capY0 + const y1 = seatYAt(x, z1) + capY1 const dz = z1 - z0 const dy = y1 - y0 const len = Math.sqrt(dz * dz + dy * dy) || 1 @@ -208,6 +414,11 @@ function addShingledTabs( const r0z = z0 + nz * ridgeH const r1y = y1 + ny * ridgeH const r1z = z1 + nz * ridgeH + const backX = x - ridgeD + const by0 = seatYAt(backX, z0) + capY0 + const by1 = seatYAt(backX, z1) + capY1 + const br0y = by0 + ny * ridgeH + const br1y = by1 + ny * ridgeH pushQuad( positions, normals, @@ -222,16 +433,29 @@ function addShingledTabs( positions, normals, uvs, - [x - ridgeD, r0y, r0z], - [x - ridgeD, r1y, r1z], - [x - ridgeD, y1, z1], - [x - ridgeD, y0, z0], + [backX, br0y, r0z], + [backX, br1y, r1z], + [backX, by1, z1], + [backX, by0, z0], [-1, 0, 0], ) } } } +function isInsideEndTaper( + x: number, + startX: number, + endX: number, + endTaper: RidgeVentEndTaper | null, +): boolean { + if (!endTaper) return false + return ( + (endTaper.taperAtStart && x <= startX + endTaper.taperLength) || + (endTaper.taperAtEnd && x >= endX - endTaper.taperLength) + ) +} + // ─── Geometry plumbing ─────────────────────────────────────────────────── function buildBufferGeometry( @@ -240,6 +464,13 @@ function buildBufferGeometry( uvs: number[], ): THREE.BufferGeometry { const geo = new THREE.BufferGeometry() + if (positions.length === 0) { + geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + geo.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + geo.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) + geo.computeBoundingSphere() + return geo + } geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) @@ -247,6 +478,274 @@ function buildBufferGeometry( return geo } +function clipRidgeVentGeometryToSegmentTrim( + geometry: THREE.BufferGeometry, + node: RidgeVentNode, + segment: RoofSegmentNode, +): THREE.BufferGeometry { + const planes = getSegmentTrimClipPlanes(segment) + if (planes.length === 0) return geometry + + const position = geometry.getAttribute('position') + const normal = geometry.getAttribute('normal') + const uv = geometry.getAttribute('uv') + if (!position || !normal || !uv) return geometry + + const positions: number[] = [] + const normals: number[] = [] + const uvs: number[] = [] + + for (let i = 0; i < position.count; i += 3) { + let polygon: RidgeVentGeometryVertex[] = [ + readGeometryVertex(position, normal, uv, i), + readGeometryVertex(position, normal, uv, i + 1), + readGeometryVertex(position, normal, uv, i + 2), + ] + + for (const plane of planes) { + polygon = clipPolygonToSegmentTrimPlane(polygon, plane, node) + if (polygon.length < 3) break + } + + if (polygon.length < 3) continue + for (let j = 1; j < polygon.length - 1; j += 1) { + pushGeometryVertex(positions, normals, uvs, polygon[0]!) + pushGeometryVertex(positions, normals, uvs, polygon[j]!) + pushGeometryVertex(positions, normals, uvs, polygon[j + 1]!) + } + } + + return buildBufferGeometry(positions, normals, uvs) +} + +function getSupportLineForVent( + segment: RoofSegmentNode, + node: RidgeVentNode, +): RidgeVentSupportLine | null { + const lines = getRidgeVentLinesForSegment(segment) + if (lines.length === 0) return null + + const centerX = finiteNumber(node.position?.[0], 0) + const centerZ = finiteNumber(node.position?.[2], 0) + const rotationY = finiteNumber(node.rotation, 0) + const dirX = Math.cos(rotationY) + const dirZ = -Math.sin(rotationY) + + let best: RidgeVentSupportLine | null = null + let bestScore = Number.POSITIVE_INFINITY + + for (const line of lines) { + const [sx, sz] = line.start + const [ex, ez] = line.end + const lineDx = ex - sx + const lineDz = ez - sz + const lineLength = Math.hypot(lineDx, lineDz) + if (!(lineLength > 1e-4)) continue + + const unitX = lineDx / lineLength + const unitZ = lineDz / lineLength + const yawPenalty = 1 - Math.abs(unitX * dirX + unitZ * dirZ) + const centerOffsetX = centerX - sx + const centerOffsetZ = centerZ - sz + const t = Math.max(0, Math.min(lineLength, centerOffsetX * unitX + centerOffsetZ * unitZ)) + const nearestX = sx + unitX * t + const nearestZ = sz + unitZ * t + const distanceSq = (centerX - nearestX) ** 2 + (centerZ - nearestZ) ** 2 + const score = distanceSq + yawPenalty * 6 + + if (score < bestScore) { + bestScore = score + const startLocalX = (sx - centerX) * dirX + (sz - centerZ) * dirZ + const endLocalX = (ex - centerX) * dirX + (ez - centerZ) * dirZ + const startRadiusSq = sx * sx + sz * sz + const endRadiusSq = ex * ex + ez * ez + const outerIsStart = startRadiusSq > endRadiusSq + const minIsStart = startLocalX <= endLocalX + best = { + startX: Math.min(startLocalX, endLocalX), + endX: Math.max(startLocalX, endLocalX), + name: line.name, + taperAtStart: minIsStart ? outerIsStart : !outerIsStart, + taperAtEnd: minIsStart ? !outerIsStart : outerIsStart, + } + } + } + + return best +} + +function readGeometryVertex( + position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + normal: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + uv: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + index: number, +): RidgeVentGeometryVertex { + return { + x: position.getX(index), + y: position.getY(index), + z: position.getZ(index), + nx: normal.getX(index), + ny: normal.getY(index), + nz: normal.getZ(index), + u: uv.getX(index), + v: uv.getY(index), + } +} + +function pushGeometryVertex( + positions: number[], + normals: number[], + uvs: number[], + vertex: RidgeVentGeometryVertex, +) { + positions.push(vertex.x, vertex.y, vertex.z) + normals.push(vertex.nx, vertex.ny, vertex.nz) + uvs.push(vertex.u, vertex.v) +} + +function clipPolygonToSegmentTrimPlane( + polygon: RidgeVentGeometryVertex[], + plane: SegmentTrimClipPlane, + node: RidgeVentNode, +): RidgeVentGeometryVertex[] { + const next: RidgeVentGeometryVertex[] = [] + let previous = polygon[polygon.length - 1]! + let previousDistance = getTrimClipDistance(previous, plane, node) + let previousInside = previousDistance <= 1e-6 + + for (const current of polygon) { + const currentDistance = getTrimClipDistance(current, plane, node) + const currentInside = currentDistance <= 1e-6 + + if (currentInside) { + if (!previousInside) { + next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance)) + } + next.push(current) + } else if (previousInside) { + next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance)) + } + + previous = current + previousDistance = currentDistance + previousInside = currentInside + } + + return next +} + +function getTrimClipDistance( + vertex: RidgeVentGeometryVertex, + plane: SegmentTrimClipPlane, + node: RidgeVentNode, +): number { + const centerX = finiteNumber(node.position?.[0], 0) + const centerZ = finiteNumber(node.position?.[2], 0) + const rotationY = finiteNumber(node.rotation, 0) + const segmentX = centerX + vertex.x * Math.cos(rotationY) + vertex.z * Math.sin(rotationY) + const segmentZ = centerZ - vertex.x * Math.sin(rotationY) + vertex.z * Math.cos(rotationY) + return plane.signedDistance(segmentX, segmentZ) +} + +function interpolateGeometryVertex( + a: RidgeVentGeometryVertex, + b: RidgeVentGeometryVertex, + distanceA: number, + distanceB: number, +): RidgeVentGeometryVertex { + const t = distanceA / (distanceA - distanceB || 1) + return { + x: lerp(a.x, b.x, t), + y: lerp(a.y, b.y, t), + z: lerp(a.z, b.z, t), + nx: lerp(a.nx, b.nx, t), + ny: lerp(a.ny, b.ny, t), + nz: lerp(a.nz, b.nz, t), + u: lerp(a.u, b.u, t), + v: lerp(a.v, b.v, t), + } +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +function getSegmentTrimClipPlanes(segment: RoofSegmentNode): SegmentTrimClipPlane[] { + const trim = normalizeRoofSegmentTrim(segment) + const planes: SegmentTrimClipPlane[] = [] + const leftX = -segment.width / 2 + trim.left + const rightX = segment.width / 2 - trim.right + const frontZ = segment.depth / 2 - trim.front + const backZ = -segment.depth / 2 + trim.back + + if (trim.left > 0) planes.push({ signedDistance: (x) => leftX - x }) + if (trim.right > 0) planes.push({ signedDistance: (x) => x - rightX }) + if (trim.front > 0) planes.push({ signedDistance: (_x, z) => z - frontZ }) + if (trim.back > 0) planes.push({ signedDistance: (_x, z) => backZ - z }) + + const diagonalPlane = ( + lineA: readonly [number, number], + lineB: readonly [number, number], + outsidePoint: readonly [number, number], + ): SegmentTrimClipPlane | null => { + const dx = lineB[0] - lineA[0] + const dz = lineB[1] - lineA[1] + const length = Math.hypot(dx, dz) + if (!(length > 0)) return null + let nx = -dz / length + let nz = dx / length + const midX = (lineA[0] + lineB[0]) / 2 + const midZ = (lineA[1] + lineB[1]) / 2 + if (nx * (outsidePoint[0] - midX) + nz * (outsidePoint[1] - midZ) < 0) { + nx *= -1 + nz *= -1 + } + return { + signedDistance: (x, z) => nx * (x - midX) + nz * (z - midZ), + } + } + + const pushDiagonalPlane = ( + lineA: readonly [number, number], + lineB: readonly [number, number], + outsidePoint: readonly [number, number], + ) => { + const plane = diagonalPlane(lineA, lineB, outsidePoint) + if (plane) planes.push(plane) + } + + if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) { + pushDiagonalPlane( + [leftX + trim.frontLeftX, frontZ], + [leftX, frontZ - trim.frontLeftZ], + [leftX - 1, frontZ + 1], + ) + } + if (trim.frontRightX > 0 && trim.frontRightZ > 0) { + pushDiagonalPlane( + [rightX, frontZ - trim.frontRightZ], + [rightX - trim.frontRightX, frontZ], + [rightX + 1, frontZ + 1], + ) + } + if (trim.backLeftX > 0 && trim.backLeftZ > 0) { + pushDiagonalPlane( + [leftX, backZ + trim.backLeftZ], + [leftX + trim.backLeftX, backZ], + [leftX - 1, backZ - 1], + ) + } + if (trim.backRightX > 0 && trim.backRightZ > 0) { + pushDiagonalPlane( + [rightX - trim.backRightX, backZ], + [rightX, backZ + trim.backRightZ], + [rightX + 1, backZ - 1], + ) + } + + return planes +} + // Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so // the shared flat normal points toward `hint`. UVs are dimension-based so // painted presets tile at world scale across the ridge length and the cap. diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index 6ca2c49e5..70508f5eb 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -23,7 +23,8 @@ import { roofSegmentLocalToBuildingLocal, snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' -import { getSurfaceY } from '../shared/roof-surface' +import { resolveRidgeSnap } from '../shared/ridge-snap' +import { getRoofTopSurfaceY } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, @@ -32,7 +33,8 @@ import RidgeVentPreview from './preview' type RidgeVentDragTarget = Pick & { localY: number - localZ: 0 + localZ: number + rotation: number } /** @@ -85,11 +87,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true) + const snap = resolveRidgeSnap(target.segment, target.localX, target.localZ) + if (!snap) return null + const yOffset = original.position[1] ?? 0 return { segment: target.segment, - localX: target.localX, - localY: getSurfaceY(target.localX, 0, target.segment), - localZ: 0, + localX: snap.localX, + localY: getRoofTopSurfaceY(snap.localX, snap.localZ, target.segment) + yOffset, + localZ: snap.localZ, + rotation: snap.rotation, } } @@ -111,7 +117,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { lastSnap = [sx, sz] } - setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0) + target.rotation) setPreviewPos( roofSegmentLocalToBuildingLocal(target.segment.id, [ target.localX, @@ -158,8 +164,8 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [target.localX, target.localY, target.localZ], - rotation: original.rotation, + position: [target.localX, original.position[1] ?? 0, target.localZ], + rotation: target.rotation, visible: true, metadata: {}, }) diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 15386ed92..635917034 100644 --- a/packages/nodes/src/ridge-vent/renderer.tsx +++ b/packages/nodes/src/ridge-vent/renderer.tsx @@ -2,7 +2,10 @@ import { type AnyNodeId, + getEffectiveRoofSurfaceMaterial, + getEffectiveSegmentSurfaceMaterial, type RidgeVentNode, + type RoofNode, type RoofSegmentNode, useLiveNodeOverrides, useRegistry, @@ -13,25 +16,54 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + getRoofMaterialArray, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap' -import { getSurfaceY } from '../shared/roof-surface' +import { getRoofTopSurfaceY } from '../shared/roof-surface' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildRidgeVentGeometry } from './geometry' -// Single white fallback for every style. Paint customisation comes from -// `node.material` / `node.materialPreset` (default: `preset-white`); the -// fallback only fires for legacy nodes that pre-date the schema default -// and shouldn't punish them with style-specific grey/metal that diverges -// from the "default white" the inspector advertises. -const defaultMaterial = new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - roughness: 0.85, - metalness: 0.1, -}) +function ridgeVentSegmentGeometryKey(segment: RoofSegmentNode | undefined): string { + if (!segment) return 'none' + const trim = segment.trim + return [ + segment.roofType, + segment.width, + segment.depth, + segment.wallHeight, + segment.pitch, + segment.wallThickness, + segment.deckThickness, + segment.overhang, + segment.shingleThickness, + segment.gambrelLowerWidthRatio, + segment.gambrelLowerHeightRatio, + segment.mansardSteepWidthRatio, + segment.mansardSteepHeightRatio, + segment.dutchHipWidthRatio, + segment.dutchHipHeightRatio, + trim.left, + trim.right, + trim.front, + trim.back, + trim.frontLeft, + trim.frontRight, + trim.backLeft, + trim.backRight, + trim.frontLeftX, + trim.frontLeftZ, + trim.frontRightX, + trim.frontRightZ, + trim.backLeftX, + trim.backLeftZ, + trim.backRightX, + trim.backRightZ, + ].join('|') +} /** * Ridge vent renderer. Sits along the ridge of a roof-segment — no @@ -66,6 +98,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { const node: RidgeVentNode = overrides ? ({ ...storeNode, ...overrides } as RidgeVentNode) : storeNode + const nodePosition = node.position ?? [0, 0, 0] const segmentStore = useScene((state) => node.roofSegmentId @@ -87,11 +120,44 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { ? ({ ...segmentStore, ...segmentOverrides } as RoofSegmentNode) : segmentStore : undefined + const parentRoof = useScene((state) => + segmentStore?.parentId + ? (state.nodes[segmentStore.parentId as AnyNodeId] as RoofNode | undefined) + : undefined, + ) + const segmentGeometryKey = ridgeVentSegmentGeometryKey(segment) + const rotationY = node.rotation ?? 0 + const snap = useMemo( + () => + segment && Math.abs(rotationY) < 1e-5 + ? resolveRidgeSnap(segment, nodePosition[0] ?? 0, nodePosition[2] ?? 0) + : null, + [segment, rotationY, nodePosition[0], nodePosition[2]], + ) + const ridgeX = snap ? snap.localX : (nodePosition[0] ?? 0) + const ridgeZ = snap ? snap.localZ : (nodePosition[2] ?? 0) + const effectiveNode = useMemo( + () => ({ + ...node, + position: [ridgeX, nodePosition[1] ?? 0, ridgeZ], + }), + [node, ridgeX, ridgeZ, nodePosition[1]], + ) - // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: `segmentGeometryKey` captures the segment fields that affect the generated mesh; depending on the whole segment would rebuild on unrelated node changes. const geometry = useMemo( - () => buildRidgeVentGeometry(node), - [node.length, node.width, node.height, node.style, node.endCaps], + () => buildRidgeVentGeometry(effectiveNode, segment), + [ + effectiveNode.length, + effectiveNode.width, + effectiveNode.height, + effectiveNode.style, + effectiveNode.endCaps, + effectiveNode.rotation, + effectiveNode.position[0], + effectiveNode.position[2], + segmentGeometryKey, + ], ) useEffect(() => () => geometry.dispose(), [geometry]) @@ -104,13 +170,62 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { // closed solid in `geometry.ts` is the right fix if the underside-view // becomes noticeable. const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const createDefaultTopMaterial = () => { + const parentSpec = parentRoof ? getEffectiveRoofSurfaceMaterial(parentRoof, 'top') : undefined + const spec = segment ? getEffectiveSegmentSurfaceMaterial(segment, 'top', parentSpec) : null + + if (typeof spec?.materialPreset === 'string') { + const resolved = createMaterialFromPresetRef(spec.materialPreset, shading) + if (resolved) return resolved + } + if (spec?.material !== undefined) { + return createMaterial(spec.material, shading) + } + + const roofMaterials = parentRoof + ? getRoofMaterialArray(parentRoof, shading, textures, colorPreset, sceneTheme) + : null + return ( + roofMaterials?.[3] ?? + createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + ) + } + + if (node.material) { + return createMaterial(node.material, shading) } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? createDefaultTopMaterial() + } + return createDefaultTopMaterial() + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.material, + node.materialPreset, + segment, + parentRoof, + ]) + + // Map vent-local geometry into the host segment's local frame (where the trim + // cut prisms live). Recompose the same pose the inner mesh group is mounted + // with — ridge snap for X/Z, slope-locked base Y + offset, yaw — so the clip + // matches the rendered placement. Computed before the early return so the + // hook order stays stable. + const localToSegment = useMemo(() => { + if (!segment) return new THREE.Matrix4() + const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment) + const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0)) + const ridgeY = baseY + RIDGE_LIFT + yOffset + return new THREE.Matrix4().compose( + new THREE.Vector3(ridgeX, ridgeY, ridgeZ), + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotationY), + new THREE.Vector3(1, 1, 1), + ) + }, [segment, ridgeX, ridgeZ, rotationY, nodePosition[1]]) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment) if (!segment) return null @@ -126,33 +241,29 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { const segPos = segment.position ?? [0, 0, 0] const segRotY = segment.rotation ?? 0 - // Lock the BASE position to the ridge so the vent always starts on the - // slope top; treat `position[1]` and `position[2]` as user-tunable OFFSETS - // off that base (Y above ridge lift, Z away from ridge centerline). So + // Lock the BASE position to the rendered roof skin so the vent always starts + // on the roof structure; treat `position[1]` and `position[2]` as user-tunable OFFSETS + // off that base (Y above the surface, Z away from ridge centerline). So // after placement the inspector's Y / Z sliders nudge the vent off the // locked ridge without losing the slope-tracking base. X is the position // along the ridge — the snap re-clamps it to the segment's ridge span. - const snap = resolveRidgeSnap(segment, node.position[0] ?? 0, 0) - const ridgeX = snap ? snap.localX : (node.position[0] ?? 0) - const baseZ = snap ? snap.localZ : 0 - const baseY = getSurfaceY(ridgeX, baseZ, segment) + RIDGE_LIFT + const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment) // Clamp legacy stored Y (absolute peak height from earlier versions) so the // vent doesn't fly off when the field was an absolute Y instead of offset. - const yOffset = Math.max(-2, Math.min(2, node.position[1] ?? 0)) - const ridgeY = baseY + yOffset - const ridgeZ = baseZ + (node.position[2] ?? 0) + const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0)) + const ridgeY = baseY + RIDGE_LIFT + yOffset return ( { const activeBuildingId = useViewer((s) => s.selection.buildingId) @@ -72,9 +71,9 @@ const RidgeVentTool = () => { ) if (!hit) return - // Project the cursor onto the segment's ridge line (clamped to the - // segment's ridge span). The preview then moves ALONG the ridge as the - // cursor moves — never off it. Flat segments have no ridge: hide. + // Project the cursor onto the nearest segment ridge/break line. + // The preview then moves along that line as the cursor moves — never + // off it. Flat segments have no ridge: hide. const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) if (!snap) { setPreviewPos(null) @@ -84,7 +83,11 @@ const RidgeVentTool = () => { const segObj = sceneRegistry.nodes.get(hit.segment.id) let ridgeWorld: [number, number, number] if (segObj) { - const ridgeLocal = new THREE.Vector3(snap.localX, hit.localY, snap.localZ) + const ridgeLocal = new THREE.Vector3( + snap.localX, + getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment), + snap.localZ, + ) segObj.updateWorldMatrix(true, false) ridgeLocal.applyMatrix4(segObj.matrixWorld) ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z] @@ -100,12 +103,16 @@ const RidgeVentTool = () => { lastSnapRef.current = [sx, sz] } - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) + setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation) setPreviewPos(worldToBuildingLocal(ridgeWorld[0], ridgeWorld[1], ridgeWorld[2])) publishRoofSurfacePlacementGuides({ roof: event.node as RoofNode, segment: hit.segment, - center: [snap.localX, hit.localY, snap.localZ], + center: [ + snap.localX, + getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment), + snap.localZ, + ], footprint: roofSurfaceFootprintFromNode(previewNode), mode: 'linear-edge', }) @@ -129,7 +136,7 @@ const RidgeVentTool = () => { name: 'Ridge Vent', roofSegmentId: hit.segment.id, position: [snap.localX, 0, snap.localZ], - rotation: 0, + rotation: snap.rotation, }) state.createNode(vent, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) diff --git a/packages/nodes/src/roof-segment/floorplan.test.ts b/packages/nodes/src/roof-segment/floorplan.test.ts new file mode 100644 index 000000000..6b0ab302e --- /dev/null +++ b/packages/nodes/src/roof-segment/floorplan.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'bun:test' +import type { RoofSegmentNode } from '@pascal-app/core' +import { getRoofSegmentPlanLinework } from './floorplan' + +function dutchSegment(overrides: Partial = {}): RoofSegmentNode { + return { + object: 'node', + id: 'rseg_test', + type: 'roof-segment', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 2.5, + pitch: 40, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + gambrelLowerWidthRatio: 0.5, + gambrelLowerHeightRatio: 0.6, + mansardSteepWidthRatio: 0.15, + mansardSteepHeightRatio: 0.7, + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + children: [], + ...overrides, + } as RoofSegmentNode +} + +describe('getRoofSegmentPlanLinework', () => { + test('draws a dutch width-axis upper ridge plus waist linework', () => { + const linework = getRoofSegmentPlanLinework(dutchSegment()) + + expect(linework.ridges).toEqual([ + [ + [-2.5, 0], + [2.5, 0], + ], + ]) + expect(linework.breaks).toEqual([ + [ + [-2.5, 1.5], + [2.5, 1.5], + ], + [ + [2.5, 1.5], + [2.5, -1.5], + ], + [ + [2.5, -1.5], + [-2.5, -1.5], + ], + [ + [-2.5, -1.5], + [-2.5, 1.5], + ], + ]) + expect(linework.hips).toContainEqual([ + [-4, 3], + [-2.5, 1.5], + ]) + expect(linework.hips).toContainEqual([ + [4, 3], + [2.5, 1.5], + ]) + }) + + test('draws a dutch depth-axis upper ridge when the depth exceeds the width', () => { + const linework = getRoofSegmentPlanLinework(dutchSegment({ width: 6, depth: 8 })) + + expect(linework.ridges).toEqual([ + [ + [0, 2.5], + [0, -2.5], + ], + ]) + expect(linework.breaks).toEqual([ + [ + [-1.5, 2.5], + [1.5, 2.5], + ], + [ + [1.5, 2.5], + [1.5, -2.5], + ], + [ + [1.5, -2.5], + [-1.5, -2.5], + ], + [ + [-1.5, -2.5], + [-1.5, 2.5], + ], + ]) + expect(linework.hips).toContainEqual([ + [-3, 4], + [-1.5, 2.5], + ]) + expect(linework.hips).toContainEqual([ + [3, -4], + [1.5, -2.5], + ]) + }) + + test('shortens dutch waist length along the ridge axis', () => { + const linework = getRoofSegmentPlanLinework(dutchSegment({ dutchWaistLengthRatio: 0.5 })) + + expect(linework.ridges).toEqual([ + [ + [-1.25, 0], + [1.25, 0], + ], + ]) + expect(linework.breaks).toEqual([ + [ + [-1.25, 1.5], + [1.25, 1.5], + ], + [ + [1.25, 1.5], + [1.25, -1.5], + ], + [ + [1.25, -1.5], + [-1.25, -1.5], + ], + [ + [-1.25, -1.5], + [-1.25, 1.5], + ], + ]) + }) +}) diff --git a/packages/nodes/src/roof-segment/floorplan.ts b/packages/nodes/src/roof-segment/floorplan.ts index e91bce9cb..fa8b93c18 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -1,9 +1,10 @@ -import type { - FloorplanGeometry, - FloorplanPoint, - GeometryContext, - RoofNode, - RoofSegmentNode, +import { + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + getDutchRoofMetrics, + type RoofNode, + type RoofSegmentNode, } from '@pascal-app/core' /** @@ -23,7 +24,7 @@ export function buildRoofSegmentFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const roof = ctx.parent as RoofNode | null - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null // Segment center in world coords. Floor-plan plots at `-rotation` so // SVG's CW-with-y-down `rotate` direction ends up matching Three.js @@ -285,30 +286,19 @@ export function getRoofSegmentPlanLinework(node: RoofSegmentNode): { break } case 'dutch': { - // Hipped lower skirt (eave corners → waist corners) + the gablet - // fold, then a gable-style ridge on top of the waist. - const i = Math.min(node.width, node.depth) * node.dutchHipWidthRatio - if (hw - i > 0.02 && hd - i > 0.02) { - const w1: PlanPt = [-hw + i, hd - i] - const w2: PlanPt = [hw - i, hd - i] - const w3: PlanPt = [hw - i, -hd + i] - const w4: PlanPt = [-hw + i, -hd + i] - hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) - breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) - if (node.width >= node.depth) { - const r1: PlanPt = [-hw + i, 0] - const r2: PlanPt = [hw - i, 0] - ridges.push([r1, r2]) - hips.push([w1, r1], [w4, r1], [w2, r2], [w3, r2]) - } else { - const r1: PlanPt = [0, hd - i] - const r2: PlanPt = [0, -hd + i] - ridges.push([r1, r2]) - hips.push([w1, r1], [w2, r1], [w3, r2], [w4, r2]) - } - } else { + const metrics = getDutchRoofMetrics(node) + if (!(metrics.waistHalfX > 0.02 && metrics.waistHalfZ > 0.02)) { pushHip() + break } + + const w1: PlanPt = [-metrics.waistHalfX, metrics.waistHalfZ] + const w2: PlanPt = [metrics.waistHalfX, metrics.waistHalfZ] + const w3: PlanPt = [metrics.waistHalfX, -metrics.waistHalfZ] + const w4: PlanPt = [-metrics.waistHalfX, -metrics.waistHalfZ] + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + ridges.push([metrics.ridgeStart, metrics.ridgeEnd]) break } } diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index 907f55fb9..ed87ec213 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -3,6 +3,10 @@ import { type AnyNode, type AnyNodeId, + createDefaultRidgeVentsForSegment, + isAutoRidgeVentEnabled, + isDefaultRidgeVentNode, + ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, type RoofType, @@ -15,6 +19,7 @@ import { PanelWrapper, SegmentedControl, SliderControl, + ToggleControl, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -26,10 +31,10 @@ const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [ { label: 'Hip', value: 'hip' }, { label: 'Gable', value: 'gable' }, { label: 'Shed', value: 'shed' }, - { label: 'Flat', value: 'flat' }, ] const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [ + { label: 'Flat', value: 'flat' }, { label: 'Gambrel', value: 'gambrel' }, { label: 'Dutch', value: 'dutch' }, { label: 'Mansard', value: 'mansard' }, @@ -44,15 +49,34 @@ const PITCH_PRESETS: { label: string; deg: number }[] = [ { label: '12/12', deg: 45 }, ] +function shouldShowTrimPlanes(metadata: unknown): boolean { + return metadataRecord(metadata).showTrimPlanes === true +} + +function metadataRecord(metadata: unknown): Record { + if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) { + return metadata as Record + } + return {} +} + export default function RoofSegmentPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined, ) + const autoRidgeVentEnabled = useScene((s) => { + const current = selectedId + ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) + : undefined + if (current?.type !== 'roof-segment') return false + return isAutoRidgeVentEnabled(current, s.nodes) + }) const handleUpdate = useCallback( (updates: Partial) => { @@ -62,15 +86,37 @@ export default function RoofSegmentPanel() { [selectedId, updateNode], ) + const handleRoofTypeChange = useCallback( + (roofType: RoofType) => { + // Switching to Dutch resets the shape parameters to their defaults so the + // gablet is well-formed regardless of the leftover values from the + // previous roof type. + handleUpdate( + roofType === 'dutch' + ? { + roofType, + dutchHipWidthRatio: ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + dutchHipHeightRatio: ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + dutchWaistLengthRatio: ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + dutchGabletRake: ROOF_SHAPE_DEFAULTS.dutchGabletRake, + dutchTopRakeThickness: ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness, + } + : { roofType }, + ) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) const handleBack = useCallback(() => { if (node?.parentId) { + setRoofHostDragArmedId(node.parentId as AnyNodeId) setSelection({ selectedIds: [node.parentId] }) } - }, [node?.parentId, setSelection]) + }, [node?.parentId, setRoofHostDragArmedId, setSelection]) const handleDuplicate = useCallback(() => { if (!node?.parentId) return @@ -117,8 +163,52 @@ export default function RoofSegmentPanel() { } }, [selectedId, node, setSelection]) + const handleAutoRidgeVentToggle = useCallback( + (checked: boolean) => { + if (!selectedId) return + const scene = useScene.getState() + const current = scene.nodes[selectedId as AnyNodeId] as RoofSegmentNode | undefined + if (current?.type !== 'roof-segment') return + + scene.updateNode(selectedId as AnyNodeId, { + metadata: { ...metadataRecord(current.metadata), autoRidgeVent: checked }, + }) + + const latest = useScene.getState().nodes[selectedId as AnyNodeId] as + | RoofSegmentNode + | undefined + if (latest?.type !== 'roof-segment') return + + const defaultVentIds = (latest.children ?? []).filter((childId) => + isDefaultRidgeVentNode(useScene.getState().nodes[childId as AnyNodeId], latest.id), + ) as AnyNodeId[] + + if (!checked) { + if (defaultVentIds.length > 0) { + useScene.getState().deleteNodes(defaultVentIds) + } + return + } + + if (defaultVentIds.length > 0) return + + const ridgeVents = createDefaultRidgeVentsForSegment(latest) + if (ridgeVents.length === 0) return + + scene.createNodes( + ridgeVents.map((ridgeVent) => ({ + node: ridgeVent, + parentId: latest.id as AnyNodeId, + })), + ) + }, + [selectedId], + ) + if (!(node && node.type === 'roof-segment' && selectedId)) return null + const showTrimPlanes = shouldShowTrimPlanes(node.metadata) + return ( handleUpdate({ roofType: v })} + onChange={(v) => handleRoofTypeChange(v)} options={ROOF_TYPE_OPTIONS} value={node.roofType} /> handleUpdate({ roofType: v })} + onChange={(v) => handleRoofTypeChange(v)} options={ROOF_TYPE_OPTIONS_2} value={node.roofType} /> + + + handleUpdate({ + metadata: { ...metadataRecord(node.metadata), showTrimPlanes: checked }, + }) + } + /> + {node.roofType !== 'shed' && node.roofType !== 'flat' && ( + + )} + + handleUpdate({ dutchHipWidthRatio: v })} @@ -261,7 +370,7 @@ export default function RoofSegmentPanel() { value={Math.round(node.dutchHipWidthRatio * 100) / 100} /> handleUpdate({ dutchHipHeightRatio: v })} @@ -270,6 +379,46 @@ export default function RoofSegmentPanel() { unit="" value={Math.round(node.dutchHipHeightRatio * 100) / 100} /> + handleUpdate({ dutchWaistLengthRatio: v })} + precision={2} + step={0.01} + unit="" + value={ + Math.round( + (node.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio) * 100, + ) / 100 + } + /> + handleUpdate({ dutchTopRakeThickness: v })} + precision={2} + step={0.01} + unit="m" + value={ + Math.round( + (node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness) * 100, + ) / 100 + } + /> + handleUpdate({ dutchGabletRake: v })} + precision={2} + step={0.01} + unit="m" + value={ + Math.round((node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake) * 100) / 100 + } + /> )} diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index 6424af86d..6b4b68218 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type BoxVentNode, type ChimneyNode, + createDefaultRidgeVentsForSegment, type DormerNode, type GutterNode, type RidgeVentNode, @@ -37,7 +38,7 @@ export default function RoofPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) - const createNode = useScene((s) => s.createNode) + const createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => @@ -176,8 +177,15 @@ export default function RoofPanel() { roofType: 'gable', position: [2, 0, 2], }) - createNode(segment, node.id as AnyNodeId) - }, [node, createNode]) + const ridgeVents = createDefaultRidgeVentsForSegment(segment) + createNodes([ + { node: segment, parentId: node.id as AnyNodeId }, + ...ridgeVents.map((ridgeVent) => ({ + node: ridgeVent, + parentId: segment.id as AnyNodeId, + })), + ]) + }, [node, createNodes]) const handleSelectSegment = useCallback( (segmentId: string) => { diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index ce863c226..d76a1e9e4 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -13,6 +13,7 @@ import { type StairNode, type StairSegmentNode, sceneRegistry, + useLiveNodeOverrides, useLiveTransforms, useScene, type WallNode, @@ -40,6 +41,38 @@ import * as THREE from 'three' /** Figma-style alignment-snap threshold (meters), matching the other tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 +function disableRaycastDuringDrag(root: THREE.Object3D | undefined): () => void { + if (!root) return () => {} + + const originals: Array<[THREE.Object3D, THREE.Object3D['raycast']]> = [] + root.traverse((child) => { + originals.push([child, child.raycast]) + child.raycast = () => {} + }) + + return () => { + for (const [child, raycast] of originals) { + child.raycast = raycast + } + } +} + +function resolvePreviewRotationY( + node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode, + localRotation: number, +): number { + if ((node.type === 'roof-segment' || node.type === 'stair-segment') && node.parentId) { + const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] + const parentRotation = + parentNode && 'rotation' in parentNode && typeof parentNode.rotation === 'number' + ? parentNode.rotation + : 0 + return parentRotation + localRotation + } + + return localRotation +} + export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode }> = ({ node: movingNode }) => { @@ -59,7 +92,9 @@ export const MoveRoofTool: React.FC<{ const previousGridPosRef = useRef<[number, number] | null>(null) const dragAnchorRef = useRef<[number, number] | null>(null) - const [previewRotation, setPreviewRotation] = useState(movingNode.rotation as number) + const [previewRotation, setPreviewRotation] = useState(() => + resolvePreviewRotationY(movingNode, movingNode.rotation as number), + ) const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { const obj = sceneRegistry.nodes.get(movingNode.id) if (obj) { @@ -124,24 +159,19 @@ export const MoveRoofTool: React.FC<{ movingNode.position[1], movingNode.position[2], ] + const movingObject = sceneRegistry.nodes.get(movingNode.id) + const restoreRaycasts = disableRaycastDuringDrag(movingObject) + + const syncHostedPreview = ( + patch: Pick, + ) => { + if (movingNode.type !== 'roof-segment' && movingNode.type !== 'stair-segment') return + useLiveNodeOverrides.getState().set(movingNode.id, patch as Record) + } - // For roof-segment moves: the selection was cleared before entering move mode, - // so isSelected=false on the parent roof, hiding individual segment meshes and - // showing only the merged mesh. We directly flip Three.js visibility so the - // user sees the individual segment tracking the cursor. - let segmentWrapperGroup: THREE.Object3D | null = null - let mergedRoofMesh: THREE.Object3D | null = null - if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') { - const segmentMesh = sceneRegistry.nodes.get(movingNode.id) - if (segmentMesh?.parent) { - // segmentMesh.parent = wrapper in Roof/StairRenderer - // segmentMesh.parent.parent = the registered roof/stair group - segmentWrapperGroup = segmentMesh.parent - const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof' - mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null - segmentWrapperGroup.visible = true - if (mergedRoofMesh) mergedRoofMesh.visible = false - } + const clearHostedPreview = () => { + if (movingNode.type !== 'roof-segment' && movingNode.type !== 'stair-segment') return + useLiveNodeOverrides.getState().clear(movingNode.id) } const resolveLevelId = () => { @@ -360,6 +390,10 @@ export const MoveRoofTool: React.FC<{ position: lastLocalPosition, rotation: pendingRotation, }) + syncHostedPreview({ + position: lastLocalPosition, + rotation: pendingRotation, + }) } const onGridClick = (event: GridEvent) => { @@ -393,6 +427,7 @@ export const MoveRoofTool: React.FC<{ triggerSFX('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [committedId] }) + clearHostedPreview() useLiveTransforms.getState().clear(movingNode.id) useEditor.getState().setMovingNodeOrigin('3d') exitMoveMode() @@ -406,6 +441,7 @@ export const MoveRoofTool: React.FC<{ const onCancel = () => { wasCancelled = true + clearHostedPreview() useLiveTransforms.getState().clear(movingNode.id) useAlignmentGuides.getState().clear() if (isNew) { @@ -436,7 +472,7 @@ export const MoveRoofTool: React.FC<{ triggerSFX('sfx:item-rotate') pendingRotation += rotationDelta - setPreviewRotation(pendingRotation) + setPreviewRotation(resolvePreviewRotationY(movingNode, pendingRotation)) // Directly update the Three.js mesh — no store update during drag const mesh = sceneRegistry.nodes.get(movingNode.id) @@ -457,6 +493,10 @@ export const MoveRoofTool: React.FC<{ rotation: pendingRotation, }) } + syncHostedPreview({ + position: lastLocalPosition, + rotation: pendingRotation, + }) } } @@ -467,11 +507,10 @@ export const MoveRoofTool: React.FC<{ window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { - // Restore segment wrapper visibility (React will re-sync on next render) - if (segmentWrapperGroup) segmentWrapperGroup.visible = false - if (mergedRoofMesh) mergedRoofMesh.visible = true + restoreRaycasts() // Clear ephemeral live transform + any alignment guides + clearHostedPreview() useLiveTransforms.getState().clear(movingNode.id) useAlignmentGuides.getState().clear() @@ -500,10 +539,14 @@ export const MoveRoofTool: React.FC<{ } }, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement]) - // Green footprint box during whole-stair / whole-roof moves. Skipped for - // segments — their cursor lives in parent-local space and the bounding box - // would render in the wrong frame. - const showBoundingBox = movingNode.type === 'stair' || movingNode.type === 'roof' + // Show the same green drag box for both top-level roofs/stairs and their + // segments. Segment cursor positions are converted into the tool's + // building-local frame above, so the box can now ride the cursor correctly. + const showBoundingBox = + movingNode.type === 'stair' || + movingNode.type === 'roof' || + movingNode.type === 'roof-segment' || + movingNode.type === 'stair-segment' return ( diff --git a/packages/nodes/src/shared/opening-placement-dimensions.ts b/packages/nodes/src/shared/opening-placement-dimensions.ts index 42d375fbb..08fedd620 100644 --- a/packages/nodes/src/shared/opening-placement-dimensions.ts +++ b/packages/nodes/src/shared/opening-placement-dimensions.ts @@ -33,7 +33,7 @@ export function buildOpeningPlacementDimensions( ctx: GeometryContext, ): FloorplanGeometry[] { const wall = ctx.parent as WallNode | null - if (!wall || wall.type !== 'wall') return [] + if (wall?.type !== 'wall') return [] if (isCurvedWall(wall)) return [] const [x1, z1] = wall.start @@ -161,7 +161,7 @@ function computeOutwardNormal( let count = 0 for (const childId of levelChildren) { const child = ctx.resolve(childId) as AnyNode | undefined - if (!child || child.type !== 'wall') continue + if (child?.type !== 'wall') continue const w = child as WallNode sumX += w.start[0] + w.end[0] sumZ += w.start[1] + w.end[1] diff --git a/packages/nodes/src/shared/pipe-run-translation-offset.ts b/packages/nodes/src/shared/pipe-run-translation-offset.ts index c9ad7d66b..d93c454a4 100644 --- a/packages/nodes/src/shared/pipe-run-translation-offset.ts +++ b/packages/nodes/src/shared/pipe-run-translation-offset.ts @@ -158,7 +158,7 @@ export function planPipeRunTranslationOffsets(args: { } const partner = nodesById[conn.nodeId] - if (!partner || partner.type !== 'pipe-fitting') return null + if (partner?.type !== 'pipe-fitting') return null const elbow = { ...(partner as PipeFittingNode), ...pipeElbowProfilePatch(profile), diff --git a/packages/nodes/src/shared/pipe-vertical-offset.ts b/packages/nodes/src/shared/pipe-vertical-offset.ts index cbadc7e87..b198359c8 100644 --- a/packages/nodes/src/shared/pipe-vertical-offset.ts +++ b/packages/nodes/src/shared/pipe-vertical-offset.ts @@ -568,7 +568,7 @@ function directRiserCollapseAction(args: { ) if (!farPort) return null const lower = nodesById[farPort.nodeId] - if (!lower || lower.type !== 'pipe-fitting') return null + if (lower?.type !== 'pipe-fitting') return null const lowerElbow = { ...(lower as PipeFittingNode), ...profilePatch } as PipeFittingNode if (lowerElbow.fittingType !== 'elbow') return null @@ -665,7 +665,7 @@ function fittingRiserAction(args: { continue } const lower = nodesById[farPort.nodeId] - if (!lower || lower.type !== 'pipe-fitting') { + if (lower?.type !== 'pipe-fitting') { if (stretch === 'stretch') return { status: 'stretch' } continue } @@ -806,7 +806,7 @@ function planVerticalOffsetsAtDy( // shows a red preview rather than dragging the network up to absorb it). if (conn.kind !== 'run') { const partner = nodesById[conn.nodeId] - if (!partner || partner.type !== 'pipe-fitting') return { status: 'invalid' } + if (partner?.type !== 'pipe-fitting') return { status: 'invalid' } const elbow = partner as PipeFittingNode const profilePatch = pipeElbowProfilePatch(profile) const profiledElbow = { ...elbow, ...profilePatch } as PipeFittingNode diff --git a/packages/nodes/src/shared/ridge-snap.test.ts b/packages/nodes/src/shared/ridge-snap.test.ts new file mode 100644 index 000000000..c4eaa93ce --- /dev/null +++ b/packages/nodes/src/shared/ridge-snap.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { resolveRidgeSnap } from './ridge-snap' + +describe('resolveRidgeSnap', () => { + test('snaps Dutch width-axis center clicks to the shortened top ridge', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + pitch: 40, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const center = resolveRidgeSnap(segment, 0, 0) + + expect(center?.localX).toBeCloseTo(0) + expect(center?.localZ).toBeCloseTo(0) + expect(center?.rotation).toBeCloseTo(0) + }) + + test('snaps Dutch depth-axis center clicks to the shortened top ridge', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 6, + depth: 8, + pitch: 40, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const center = resolveRidgeSnap(segment, 0, 0) + + expect(center?.localX).toBeCloseTo(0) + expect(center?.localZ).toBeCloseTo(0) + expect(center?.rotation).toBeCloseTo(Math.PI / 2) + }) + + test('snaps Dutch shoulder clicks onto the extended lower hip seam up to the rake end', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + pitch: 40, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const snap = resolveRidgeSnap(segment, 3.8, 2.8) + + expect(snap).not.toBeNull() + expect(snap?.localX).toBeCloseTo(3.84, 2) + expect(snap?.localZ).toBeCloseTo(2.77, 2) + expect(Math.abs(snap?.rotation ?? 0)).toBeGreaterThan(0.1) + expect(Math.abs(Math.abs(snap?.rotation ?? 0) - Math.PI / 2)).toBeGreaterThan(0.1) + }) + + test('snaps mansard center clicks to the upper top ridge', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'mansard', + width: 8, + depth: 6, + pitch: 40, + }) + + const center = resolveRidgeSnap(segment, 0, 0) + + expect(center?.localX).toBeCloseTo(0) + expect(center?.localZ).toBe(0) + expect(center?.rotation).toBeCloseTo(0) + }) + + test('snaps mansard lower-slope clicks to the nearest lower-slope vent line', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'mansard', + width: 8, + depth: 6, + pitch: 40, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const frontRight = resolveRidgeSnap(segment, 3.5, 2.5) + const frontLeft = resolveRidgeSnap(segment, -3.5, 2.5) + + expect(frontRight?.localX).toBeGreaterThan(0) + expect(frontRight?.localZ).toBeGreaterThan(0) + expect(frontLeft?.localX).toBeLessThan(0) + expect(frontLeft?.localZ).toBeGreaterThan(0) + expect(Math.abs(frontRight?.rotation ?? 0)).toBeGreaterThan(0.1) + expect(Math.abs(frontRight?.rotation ?? 0)).toBeLessThan(Math.PI / 2 - 0.1) + }) +}) diff --git a/packages/nodes/src/shared/ridge-snap.ts b/packages/nodes/src/shared/ridge-snap.ts index 8a2af4760..bb2f56970 100644 --- a/packages/nodes/src/shared/ridge-snap.ts +++ b/packages/nodes/src/shared/ridge-snap.ts @@ -1,48 +1,87 @@ -import type { RoofSegmentNode } from '@pascal-app/core' +import { + getRidgeVentLinesForSegment, + getRoofSegmentVisibleTopBounds, + type RoofSegmentNode, +} from '@pascal-app/core' /** * Shared ridge-line snap math for ridge-vent placement + move tools. * - * Ridge vents must sit centered on the segment's ridge — off-ridge the - * cap's far half dips into the higher part of the slope ("goes inside" - * the roof). So the placement tools clamp the cursor onto the ridge: - * closest-point projection along the segment's local X axis, with the X - * span clipped to where a real ridge actually exists for that roof type. + * Ridge vents must sit centered on a roof break line — off-line the cap's + * far half dips into the higher part of the slope ("goes inside" the roof). + * So the placement tools clamp the cursor onto the nearest generated ridge + * line, preserving the line's yaw for hip / lower-slope runs. * - * Per roof type (the segment's ridge runs along the segment's local X): - * - gable / gambrel / dutch / mansard: ridge spans the full width. + * Per roof type: + * - gable / gambrel: ridge spans the full width. + * - mansard: top ridge, upper hip runs, plus lower-slope runs on all + * four steep lower faces. + * - dutch: top ridge between the gablet waists plus four hip runs down + * to the eave corners (the gablet ends are vertical walls, not ridges). * - hip: ridge is shortened by the hipped ends — spans width − depth. * A square hip (width ≤ depth) collapses to a single apex point. * - shed: no true ridge — snap to the high eave (z = -depth/2). * - flat: no ridge at all → return null. */ -// Standard lift above the analytical slope surface so the cap reads as -// sitting on the shingle course rather than clipping into it. Shared -// with the renderer so live ridge-Y derivation matches placement. -export const RIDGE_LIFT = 0.12 +// Ridge vents seat directly onto the analytical roof surface; any visible +// thickness belongs in the vent geometry itself, not in a renderer lift. +export const RIDGE_LIFT = 0.09 export type RidgeSnap = { /** Segment-local X of the snapped ridge position. */ localX: number - /** Segment-local Z of the snapped ridge position (0 for peaked roofs). */ + /** Segment-local Z of the snapped ridge position. */ localZ: number + /** Segment-local yaw matching the snapped ridge line. */ + rotation: number } export function resolveRidgeSnap( segment: RoofSegmentNode, cursorLocalX: number, - _cursorLocalZ: number, + cursorLocalZ: number, ): RidgeSnap | null { const roofType = segment.roofType ?? 'gable' if (roofType === 'flat') return null - const halfW = (segment.width ?? 0) / 2 - const halfD = (segment.depth ?? 0) / 2 + const lines = + roofType === 'shed' + ? getShedHighEaveLine(segment) + : getRidgeVentLinesForSegment(segment).map(({ start, end }) => ({ start, end })) + if (lines.length === 0) return null - const ridgeZ = roofType === 'shed' ? -halfD : 0 - const ridgeHalfLength = roofType === 'hip' ? Math.max(0, halfW - halfD) : halfW - const localX = Math.max(-ridgeHalfLength, Math.min(ridgeHalfLength, cursorLocalX)) + let best: RidgeSnap | null = null + let bestDistanceSq = Number.POSITIVE_INFINITY - return { localX, localZ: ridgeZ } + for (const line of lines) { + const [sx, sz] = line.start + const [ex, ez] = line.end + const dx = ex - sx + const dz = ez - sz + const lengthSq = dx * dx + dz * dz + const t = + lengthSq <= 1e-8 + ? 0 + : Math.max(0, Math.min(1, ((cursorLocalX - sx) * dx + (cursorLocalZ - sz) * dz) / lengthSq)) + const localX = sx + dx * t + const localZ = sz + dz * t + const distanceSq = (cursorLocalX - localX) ** 2 + (cursorLocalZ - localZ) ** 2 + + if (distanceSq < bestDistanceSq) { + bestDistanceSq = distanceSq + best = { + localX, + localZ, + rotation: Math.atan2(-dz, dx), + } + } + } + + return best +} + +function getShedHighEaveLine(segment: RoofSegmentNode) { + const { minX, maxX, minZ } = getRoofSegmentVisibleTopBounds(segment) + return [{ start: [minX, minZ] as [number, number], end: [maxX, minZ] as [number, number] }] } diff --git a/packages/nodes/src/shared/roof-face-host.tsx b/packages/nodes/src/shared/roof-face-host.tsx index 1083b0156..3a0e1027d 100644 --- a/packages/nodes/src/shared/roof-face-host.tsx +++ b/packages/nodes/src/shared/roof-face-host.tsx @@ -40,7 +40,7 @@ export function RoofFaceHostFrame({ [storeSegment, liveOverride], ) - if (!segment || segment.type !== 'roof-segment' || !roofFace) return null + if (segment?.type !== 'roof-segment' || !roofFace) return null const frame = getRoofWallFaceFrame(segment, roofFace) return ( diff --git a/packages/nodes/src/shared/roof-opening-host.ts b/packages/nodes/src/shared/roof-opening-host.ts index ad37ad7d4..ffea78559 100644 --- a/packages/nodes/src/shared/roof-opening-host.ts +++ b/packages/nodes/src/shared/roof-opening-host.ts @@ -33,7 +33,7 @@ type SceneReader = { get: (id: AnyNodeId) => unknown } function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) { if (!(node.roofSegmentId && node.roofFace)) return null const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) } } diff --git a/packages/nodes/src/shared/roof-segment-hit.ts b/packages/nodes/src/shared/roof-segment-hit.ts index 262c847cd..b5f9e7496 100644 --- a/packages/nodes/src/shared/roof-segment-hit.ts +++ b/packages/nodes/src/shared/roof-segment-hit.ts @@ -9,6 +9,7 @@ import { import * as THREE from 'three' const worldPoint = new THREE.Vector3() +const localPoint = new THREE.Vector3() export type RoofSegmentHit = { segment: RoofSegmentNode @@ -59,7 +60,7 @@ export function resolveRoofSegmentHit( const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) continue segObj.updateWorldMatrix(true, false) - const local = segObj.worldToLocal(worldPoint.clone()) + const local = segObj.worldToLocal(localPoint.copy(worldPoint)) if (!firstSegment) firstSegment = { seg, segObj } @@ -81,7 +82,7 @@ export function resolveRoofSegmentHit( if (best) return best.hit if (firstSegment) { - const local = firstSegment.segObj.worldToLocal(worldPoint.clone()) + const local = firstSegment.segObj.worldToLocal(localPoint.copy(worldPoint)) return { segment: firstSegment.seg, localX: local.x, diff --git a/packages/nodes/src/shared/roof-surface-placement-guides.ts b/packages/nodes/src/shared/roof-surface-placement-guides.ts index 1834fdc06..12e9c8772 100644 --- a/packages/nodes/src/shared/roof-surface-placement-guides.ts +++ b/packages/nodes/src/shared/roof-surface-placement-guides.ts @@ -29,6 +29,11 @@ const EQUAL_SPACING_THRESHOLD_M = 0.03 const tmp = new THREE.Vector3() const tmpA = new THREE.Vector3() const tmpB = new THREE.Vector3() +const ROOF_SURFACE_FOOTPRINT_CACHE_MAX = 160 +const roofSurfaceFootprintCache = new Map< + string, + Pick | null +>() export type RoofSurfaceGuideMode = 'side-center' | 'linear-edge' @@ -80,7 +85,7 @@ export function roofSurfaceFootprintFromNode( options?: { segment?: RoofSegmentNode }, ): RoofSurfaceGuideFootprint { const n = node as Record - const geometryBounds = geometryFootprintForNode(n, options?.segment) + const geometryBounds = cachedGeometryFootprintForNode(n, options?.segment) if (geometryBounds) { return { ...geometryBounds, @@ -127,6 +132,43 @@ export function roofSurfaceFootprintFromNode( } } +function cachedGeometryFootprintForNode( + node: Record, + segment: RoofSegmentNode | undefined, +): Pick | null { + const key = geometryFootprintCacheKey(node, segment) + if (roofSurfaceFootprintCache.has(key)) { + const cached = roofSurfaceFootprintCache.get(key) + return cached ? { ...cached } : null + } + + const footprint = geometryFootprintForNode(node, segment) + roofSurfaceFootprintCache.set(key, footprint ? { ...footprint } : null) + if (roofSurfaceFootprintCache.size > ROOF_SURFACE_FOOTPRINT_CACHE_MAX) { + const oldestKey = roofSurfaceFootprintCache.keys().next().value + if (oldestKey) roofSurfaceFootprintCache.delete(oldestKey) + } + return footprint +} + +function geometryFootprintCacheKey( + node: Record, + segment: RoofSegmentNode | undefined, +): string { + const type = typeof node.type === 'string' ? node.type : 'unknown' + const segmentKey = type === 'chimney' && segment ? `|segment:${stableCacheKey(segment)}` : '' + return `${type}|node:${stableCacheKey(node)}${segmentKey}` +} + +function stableCacheKey(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map((item) => stableCacheKey(item)).join(',')}]` + const entries = Object.entries(value as Record) + .filter(([, entryValue]) => typeof entryValue !== 'function' && entryValue !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableCacheKey(entryValue)}`).join(',')}}` +} + function geometryFootprintForNode( node: Record, segment: RoofSegmentNode | undefined, diff --git a/packages/nodes/src/shared/roof-surface.test.ts b/packages/nodes/src/shared/roof-surface.test.ts index 83cd0b335..b0dd4bf04 100644 --- a/packages/nodes/src/shared/roof-surface.test.ts +++ b/packages/nodes/src/shared/roof-surface.test.ts @@ -40,6 +40,17 @@ describe('getDownSlopeYaw', () => { test('flat segment has no down-slope direction (yaw 0)', () => { expect(getDownSlopeYaw(0, 0, fixtureSegment({ roofType: 'flat' }))).toBe(0) }) + test('dutch width-axis shoulder falls toward the side eaves', () => { + expect(getDownSlopeYaw(3.8, 0, fixtureSegment({ roofType: 'dutch' }))).toBeCloseTo(Math.PI / 2) + }) + test('dutch width-axis front skirt yaws toward the front eave', () => { + expect(getDownSlopeYaw(0, 1, fixtureSegment({ roofType: 'dutch' }))).toBeCloseTo(0) + }) + test('dutch depth-axis top gable falls toward the side eaves', () => { + expect( + getDownSlopeYaw(1, 0, fixtureSegment({ roofType: 'dutch', width: 6, depth: 8 })), + ).toBeCloseTo(Math.PI / 2) + }) }) describe('getRoofSurfaceFaceBoundsAt', () => { @@ -63,4 +74,30 @@ describe('getRoofSurfaceFaceBoundsAt', () => { expect(ridgeInterval?.[0]).toBeGreaterThan(-2) expect(ridgeInterval?.[1]).toBeLessThan(2) }) + + test('mansard top surface rises to a center ridge instead of staying flat', () => { + const segment = fixtureSegment({ + roofType: 'mansard', + mansardSteepWidthRatio: 0.15, + mansardSteepHeightRatio: 0.7, + }) + + const center = getRoofSurfaceFaceBoundsAt(segment, 0, 0).surfaceYAt(0, 0) + const offRidge = getRoofSurfaceFaceBoundsAt(segment, 0, 0.5).surfaceYAt(0, 0.5) + + expect(center).toBeGreaterThan(offRidge) + }) + + test('dutch top surface rises from the waist to the center ridge', () => { + const segment = fixtureSegment({ + roofType: 'dutch', + dutchHipWidthRatio: 0.2, + dutchHipHeightRatio: 0.6, + }) + + const center = getRoofSurfaceFaceBoundsAt(segment, 0, 0).surfaceYAt(0, 0) + const waist = getRoofSurfaceFaceBoundsAt(segment, 0, 1.2).surfaceYAt(0, 1.2) + + expect(center).toBeGreaterThan(waist) + }) }) diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts index 033b293f1..aa6369563 100644 --- a/packages/nodes/src/shared/roof-surface.ts +++ b/packages/nodes/src/shared/roof-surface.ts @@ -1,9 +1,11 @@ import { + getRoofModuleFaces, getRoofSegmentSurfaceY, + getRoofShapeInsets, + getRoofShapeRatios, getSegmentSlopeFrame, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, - type RoofType, } from '@pascal-app/core' import * as THREE from 'three' @@ -17,6 +19,10 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe return getRoofSegmentSurfaceY(seg, lx, lz) } +export function getRoofTopSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number { + return getRoofSurfaceFaceBoundsAt(seg, lx, lz).surfaceYAt(lx, lz) +} + export type RoofSurfacePoint2D = [number, number] export type RoofSurfaceFaceBounds = { @@ -36,9 +42,7 @@ export function getRoofSurfaceFaceBoundsAt( lz: number, ): RoofSurfaceFaceBounds { const faces = getRoofSurfaceFaces(segment) - const face = - faces.find((candidate) => pointInPolygon([lx, lz], candidate.polygon)) ?? - nearestFaceToPoint(faces, [lx, lz]) + const face = topmostFaceAtPoint(faces, lx, lz) ?? nearestFaceToPoint(faces, [lx, lz]) const { polygon } = face const xs = polygon.map((point) => point[0]) @@ -61,23 +65,52 @@ type RoofSurfaceFace = { vertices: FaceVertex[] } type FaceVertex = { x: number; y: number; z: number } -type FaceInsets = { - iF?: number - iB?: number - iL?: number - iR?: number - dutchI?: number -} -type FaceShapeRatios = { - gambrelLowerWidthRatio: number - mansardSteepWidthRatio: number - dutchHipWidthRatio: number -} const SHINGLE_SURFACE_EPSILON = 0.02 const FACE_TOLERANCE = 1e-6 +const ROOF_SURFACE_FACE_CACHE_MAX = 128 +const roofSurfaceFaceCache = new Map() +const _downSlopeYawNormal = new THREE.Vector3() +const _surfaceQuatRight = new THREE.Vector3() +const _surfaceQuatForward = new THREE.Vector3() +const _surfaceQuatMatrix = new THREE.Matrix4() function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { + const key = roofSurfaceFaceCacheKey(segment) + const cached = roofSurfaceFaceCache.get(key) + if (cached) return cached + + const faces = buildRoofSurfaceFaces(segment) + roofSurfaceFaceCache.set(key, faces) + if (roofSurfaceFaceCache.size > ROOF_SURFACE_FACE_CACHE_MAX) { + const oldestKey = roofSurfaceFaceCache.keys().next().value + if (oldestKey) roofSurfaceFaceCache.delete(oldestKey) + } + return faces +} + +function roofSurfaceFaceCacheKey(segment: RoofSegmentNode): string { + return [ + segment.roofType, + segment.width, + segment.depth, + segment.wallHeight, + segment.wallThickness, + segment.deckThickness, + segment.overhang, + segment.shingleThickness, + segment.pitch, + segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, + segment.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio, + segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio, + segment.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio, + segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + segment.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + ].join('|') +} + +function buildRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment) @@ -122,39 +155,43 @@ function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { const dropTop = Math.min(1, maxDrop * 0.4) const topBaseY = shinBotWh - dropTop - const insetsTop = getRoofFaceInsets( + const dutchHipWidthRatio = segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio + const insetsTop = getRoofShapeInsets({ roofType, width, depth, - shinTopWh, - topBaseY, - false, - shinTopW, - shinTopD, + wh: shinTopWh, + baseY: topBaseY, + isVoid: false, + brushW: shinTopW, + brushD: shinTopD, tanTheta, shingleThickness, - ) - const shapeRatios = { - gambrelLowerWidthRatio: - segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, - mansardSteepWidthRatio: - segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio, - dutchHipWidthRatio: segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, - } - - return getRoofModuleFaces( - roofType, - shinTopW, - shinTopD, - shinTopWh, - shinTopRh, - topBaseY, - insetsTop, - width, - depth, + dutchHipWidthRatio, + }) + const shapeRatios = getRoofShapeRatios({ + gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio, + mansardSteepWidthRatio: segment.mansardSteepWidthRatio, + dutchHipWidthRatio, + dutchHipHeightRatio: segment.dutchHipHeightRatio, + dutchWaistLengthRatio: segment.dutchWaistLengthRatio, + dutchGabletRake: segment.dutchGabletRake, + }) + + return getRoofModuleFaces({ + type: roofType, + w: shinTopW, + d: shinTopD, + wh: shinTopWh, + rh: shinTopRh, + baseY: topBaseY, + insets: insetsTop, + baseW: width, + baseD: depth, tanTheta, shapeRatios, - ) + dutchTopRakeThickness: segment.dutchTopRakeThickness, + }) .filter((face) => faceNormalY(face) > SHINGLE_SURFACE_EPSILON) .map((face) => { const vertices = face.map((point) => ({ ...point, z: point.z + transZ })) @@ -166,199 +203,23 @@ function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { .filter((face) => face.polygon.length >= 3) } -function getRoofFaceInsets( - roofType: RoofType, - width: number, - depth: number, - wh: number, - baseY: number, - isVoid: boolean, - brushW: number, - brushD: number, - tanTheta: number, - shingleThickness: number, -): FaceInsets { - let inset = (wh - baseY) * tanTheta - const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005 - if (inset > maxSafeInset) inset = maxSafeInset - - let iF = 0 - let iB = 0 - let iL = 0 - let iR = 0 - if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') { - iF = inset - iB = inset - iL = inset - iR = inset - } else if (roofType === 'gable' || roofType === 'gambrel') { - iF = inset - iB = inset - } else if (roofType === 'shed') { - iF = inset - } - - let dutchI = Math.min(width, depth) * 0.25 - if (isVoid) dutchI += shingleThickness - return { iF, iB, iL, iR, dutchI } -} +function topmostFaceAtPoint( + faces: RoofSurfaceFace[], + lx: number, + lz: number, +): RoofSurfaceFace | null { + let best: RoofSurfaceFace | null = null + let bestY = Number.NEGATIVE_INFINITY -function getRoofModuleFaces( - type: RoofType, - w: number, - d: number, - wh: number, - rh: number, - baseY: number, - insets: FaceInsets, - baseW: number, - baseD: number, - tanTheta: number, - shapeRatios: FaceShapeRatios, -): FaceVertex[][] { - const v = (x: number, y: number, z: number): FaceVertex => ({ x, y, z }) - const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets - - const b1 = v(-w / 2 + iL, baseY, d / 2 - iF) - const b2 = v(w / 2 - iR, baseY, d / 2 - iF) - const b3 = v(w / 2 - iR, baseY, -d / 2 + iB) - const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB) - const bottom = [b4, b3, b2, b1] - - const e1 = v(-w / 2, wh, d / 2) - const e2 = v(w / 2, wh, d / 2) - const e3 = v(w / 2, wh, -d / 2) - const e4 = v(-w / 2, wh, -d / 2) - - const faces: FaceVertex[][] = [] - faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom) - - const h = wh + Math.max(0.001, rh) - - if (type === 'flat' || rh === 0) { - faces.push([e1, e2, e3, e4]) - } else if (type === 'gable') { - const r1 = v(-w / 2, h, 0) - const r2 = v(w / 2, h, 0) - faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) - } else if (type === 'hip') { - if (Math.abs(w - d) < 0.01) { - const r = v(0, h, 0) - faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r]) - } else if (w >= d) { - const r1 = v(-w / 2 + d / 2, h, 0) - const r2 = v(w / 2 - d / 2, h, 0) - faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) - } else { - const r1 = v(0, h, d / 2 - w / 2) - const r2 = v(0, h, -d / 2 + w / 2) - faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) - } - } else if (type === 'shed') { - const t1 = v(-w / 2, h, -d / 2) - const t2 = v(w / 2, h, -d / 2) - faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1]) - } else if (type === 'gambrel') { - const mz = (baseD / 2) * shapeRatios.gambrelLowerWidthRatio - const dist = d / 2 - mz - const mh = wh + dist * (tanTheta || 0) - - const m1 = v(-w / 2, mh, mz) - const m2 = v(w / 2, mh, mz) - const m3 = v(w / 2, mh, -mz) - const m4 = v(-w / 2, mh, -mz) - const r1 = v(-w / 2, h, 0) - const r2 = v(w / 2, h, 0) - faces.push( - [e4, e1, m1, r1, m4], - [e2, e3, m3, r2, m2], - [e1, e2, m2, m1], - [m1, m2, r2, r1], - [e3, e4, m4, m3], - [m3, m4, r1, r2], - ) - } else if (type === 'mansard') { - const i = Math.min(baseW, baseD) * shapeRatios.mansardSteepWidthRatio - const mh = wh + i * (tanTheta || 0) - - const m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2) - const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2) - const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2) - const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2) - if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) { - if (w >= d) { - const r1 = v(-w / 2 + d / 2, h, 0) - const r2 = v(w / 2 - d / 2, h, 0) - faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) - } else { - const r1 = v(0, h, d / 2 - w / 2) - const r2 = v(0, h, -d / 2 + w / 2) - faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) - } - } else { - faces.push( - [t1, t2, t3, t4], - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m1, m2, t2, t1], - [m2, m3, t3, t2], - [m3, m4, t4, t3], - [m4, m1, t1, t4], - ) - } - } else if (type === 'dutch') { - const i = - insets.dutchI !== undefined - ? insets.dutchI - : Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio - const mh = wh + i * (tanTheta || 0) - - if (w >= d) { - const m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const r1 = v(-w / 2 + i, h, 0) - const r2 = v(w / 2 - i, h, 0) - - faces.push( - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m4, m1, r1], - [m2, m3, r2], - [m1, m2, r2, r1], - [m3, m4, r1, r2], - ) - } else { - const m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const r1 = v(0, h, d / 2 - i) - const r2 = v(0, h, -d / 2 + i) - - faces.push( - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m1, m2, r1], - [m3, m4, r2], - [m2, m3, r2, r1], - [m4, m1, r1, r2], - ) - } + for (const face of faces) { + if (!pointInPolygon([lx, lz], face.polygon)) continue + const y = surfaceYOnFace(face.vertices, lx, lz) + if (y === null || y <= bestY) continue + best = face + bestY = y } - return faces + return best } function faceNormalY(face: FaceVertex[]): number { @@ -527,15 +388,20 @@ function lineInterval( // down-slope direction (cos θ horizontal + −sin θ vertical). Crossing // them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz), // equivalently (dx · tan θ, 1, dz · tan θ) un-normalised. -function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 { - return new THREE.Vector3(dx * tan, 1, dz * tan).normalize() +function buildSlopeNormal(dx: number, dz: number, tan: number, out: THREE.Vector3): THREE.Vector3 { + return out.set(dx * tan, 1, dz * tan).normalize() } -export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 { +export function getAnalyticalNormal( + lx: number, + lz: number, + seg: RoofSegmentNode, + out = new THREE.Vector3(), +): THREE.Vector3 { const { roofType, depth, width } = seg const slope = getSegmentSlopeFrame(seg) if (slope.activeRh === 0 || slope.tanTheta === 0) { - return new THREE.Vector3(0, 1, 0) + return out.set(0, 1, 0) } const primaryTan = slope.tanTheta const halfW = width / 2 @@ -557,15 +423,15 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode const upperRise = slope.activeRh * (1 - lowerHeightRatio) const upperRun = mz const upperTan = upperRun > 0 ? upperRise / upperRun : 0 - return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan) + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan, out) } } - return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out) } // Single slope falling toward +Z (ridge at -Z, eave at +Z). if (roofType === 'shed') { - return buildSlopeNormal(0, 1, primaryTan) + return buildSlopeNormal(0, 1, primaryTan, out) } // 4-sided slopes: the dominant axis chooses which face the point sits @@ -574,10 +440,9 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode // ends and gable sides — both share the same primaryTan from the // slope frame, so directional dispatch is enough. if (roofType === 'hip') { - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) + const onZ = (halfD > 0 ? Math.abs(lz) / halfD : 0) >= (halfW > 0 ? Math.abs(lx) / halfW : 0) + if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan, out) } if (roofType === 'mansard') { @@ -597,21 +462,49 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode const topRun = Math.max(0, Math.min(halfW, halfD) - inset) tan = topRun > 0 ? topRise / topRun : 0 } - if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan) + if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan, out) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan, out) } if (roofType === 'dutch') { - // Hip on the short-axis ends, gable on the long-axis sides. Both - // share the primary pitch on their primary (eave-band) face, so the - // approximation collapses to "pick the dominant axis." - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) + const inset = + Math.min(width, depth) * (seg.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio) + const heightRatio = seg.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio + const lengthRatio = seg.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio + const lowerRise = slope.activeRh * heightRatio + + if (width >= depth) { + const waistHalfX = Math.max(0, (halfW - inset) * lengthRatio) + const waistHalfZ = Math.max(0, halfD - inset) + if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) { + const topRise = slope.activeRh * (1 - heightRatio) + const topTan = waistHalfZ > 0 ? topRise / waistHalfZ : 0 + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, topTan, out) + } + + const xRun = Math.max(0.0001, halfW - waistHalfX) + const zRun = Math.max(0.0001, halfD - waistHalfZ) + const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0 + const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0 + return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize() + } + + const waistHalfX = Math.max(0, halfW - inset) + const waistHalfZ = Math.max(0, (halfD - inset) * lengthRatio) + if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) { + const topRise = slope.activeRh * (1 - heightRatio) + const topTan = waistHalfX > 0 ? topRise / waistHalfX : 0 + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, topTan, out) + } + + const xRun = Math.max(0.0001, halfW - waistHalfX) + const zRun = Math.max(0.0001, halfD - waistHalfZ) + const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0 + const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0 + return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize() } - return new THREE.Vector3(0, 1, 0) + return out.set(0, 1, 0) } // ─── Quaternion helper ─────────────────────────────────────────────── @@ -628,8 +521,7 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni // depending on which slope they sit on, and registry chevrons end up // anchored to the wrong edge. Projecting +X keeps the basis stable // across slope-flips that share the same X axis. - const wx = new THREE.Vector3(1, 0, 0) - const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal))) + const right = _surfaceQuatRight.set(1, 0, 0).addScaledVector(normal, -normal.x) if (right.lengthSq() < 1e-6) { // Degenerate: normal is parallel to ±X. Fall back to +Z so the basis // is still well-defined; this is the wall-like edge case (vertical @@ -638,9 +530,9 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni } else { right.normalize() } - const forward = new THREE.Vector3().crossVectors(right, normal).normalize() - const m = new THREE.Matrix4().makeBasis(right, normal, forward) - return out.setFromRotationMatrix(m) + _surfaceQuatForward.crossVectors(right, normal).normalize() + _surfaceQuatMatrix.makeBasis(right, normal, _surfaceQuatForward) + return out.setFromRotationMatrix(_surfaceQuatMatrix) } // Yaw (about the surface normal, composed AFTER `surfaceQuatFromNormal`) @@ -650,7 +542,7 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni // → 0, −Z → π, +X → +π/2, −X → −π/2. Kept next to `surfaceQuatFromNormal` // so the two stay in lockstep — the formula is only valid for its basis. export function getDownSlopeYaw(lx: number, lz: number, seg: RoofSegmentNode): number { - const n = getAnalyticalNormal(lx, lz, seg) + const n = getAnalyticalNormal(lx, lz, seg, _downSlopeYawNormal) if (n.x === 0 && n.z === 0) return 0 return Math.atan2(n.x * n.y, n.z) } diff --git a/packages/nodes/src/shared/run-translation-offset.ts b/packages/nodes/src/shared/run-translation-offset.ts index 44bf540f7..d327da0bf 100644 --- a/packages/nodes/src/shared/run-translation-offset.ts +++ b/packages/nodes/src/shared/run-translation-offset.ts @@ -166,7 +166,7 @@ export function planRunTranslationOffsets(args: { } const partner = nodesById[conn.nodeId] - if (!partner || partner.type !== 'duct-fitting') return null + if (partner?.type !== 'duct-fitting') return null const elbow = { ...(partner as DuctFittingNode), ...elbowProfilePatch(profile), diff --git a/packages/nodes/src/shared/use-segment-trim-clip.tsx b/packages/nodes/src/shared/use-segment-trim-clip.tsx new file mode 100644 index 000000000..471ba979f --- /dev/null +++ b/packages/nodes/src/shared/use-segment-trim-clip.tsx @@ -0,0 +1,202 @@ +import { + type AnyNodeId, + normalizeRoofSegmentTrim, + type RoofSegmentNode, + type RoofSegmentTrim, + useLiveNodeOverrides, +} from '@pascal-app/core' +import { clipGeometryBySegmentTrim } from '@pascal-app/viewer' +import { useEffect, useMemo } from 'react' +import * as THREE from 'three' +import { Matrix4 } from 'three' + +// Does this segment remove any material? Mirrors the viewer's internal +// `hasSegmentTrim` (not exported) — the clip is a no-op otherwise, so the +// common (untrimmed) case skips all CSG work. +function segmentHasTrim(segment: RoofSegmentNode): boolean { + const t = normalizeRoofSegmentTrim(segment) + return ( + t.left > 0 || + t.right > 0 || + t.front > 0 || + t.back > 0 || + t.frontLeftX > 0 || + t.frontLeftZ > 0 || + t.frontRightX > 0 || + t.frontRightZ > 0 || + t.backLeftX > 0 || + t.backLeftZ > 0 || + t.backRightX > 0 || + t.backRightZ > 0 + ) +} + +// A stable key for the segment's trim + footprint, so the memo only re-clips +// when the cut shape actually changes (not on unrelated segment edits like +// material). +function segmentTrimKey(segment: RoofSegmentNode | undefined): string { + if (!segment) return 'none' + const t = normalizeRoofSegmentTrim(segment) + return JSON.stringify([ + segment.width, + segment.depth, + t.left, + t.right, + t.front, + t.back, + t.frontLeftX, + t.frontLeftZ, + t.frontRightX, + t.frontRightZ, + t.backLeftX, + t.backLeftZ, + t.backRightX, + t.backRightZ, + ]) +} + +/** + * Clip a roof accessory's geometry by its host segment's trim, so the part of + * the accessory standing in a trimmed-away region is sliced off exactly like + * the roof shell. + * + * `geometry` is in accessory-local space; `localToSegment` maps it into the + * segment-local frame the trim cut prisms live in (compose the same + * `node.position` + inner-group quaternion the renderer mounts the mesh with). + * The function bakes that transform, runs `clipGeometryBySegmentTrim`, then + * strips the transform back off so the returned geometry is still + * accessory-local and drops straight into the renderer's existing mesh. + * + * Returns the input geometry untouched when the segment is missing or has no + * trim — zero cost for the overwhelmingly common case. The derived (clipped) + * geometry is owned by the hook and disposed on change / unmount; the input + * geometry is never consumed (we clip a clone), so the caller keeps owning it. + */ +export function useSegmentTrimClippedGeometry( + geometry: THREE.BufferGeometry | null, + segment: RoofSegmentNode | undefined, + localToSegment: THREE.Matrix4, +): THREE.BufferGeometry | null { + // Subscribe to the segment's live trim override so the accessory re-slices + // in lockstep with the trim handle drag (the editor publishes the in-flight + // trim to `useLiveNodeOverrides`; the store only updates on commit). Without + // this the accessory would only re-clip once the drag is released. + const liveTrim = useLiveNodeOverrides((s) => + segment ? (s.get(segment.id as AnyNodeId)?.trim as RoofSegmentTrim | undefined) : undefined, + ) + const effectiveSegment = useMemo( + () => (segment && liveTrim ? { ...segment, trim: liveTrim } : segment), + [segment, liveTrim], + ) + + const trimKey = segmentTrimKey(effectiveSegment) + // Matrix identity isn't stable across renders; key on its elements. + const matrixKey = localToSegment.elements.join(',') + + const clipped = useMemo(() => { + if (!geometry || !effectiveSegment || !segmentHasTrim(effectiveSegment)) return null + const baked = geometry.clone() + baked.applyMatrix4(localToSegment) + const result = clipGeometryBySegmentTrim(baked, effectiveSegment) + if (!result) return null + // `clipGeometryBySegmentTrim` returns the same object when there's no trim + // (already guarded above) or a fresh clone otherwise; either way it's our + // `baked` clone or its descendant, safe to mutate + own. + const inverse = new Matrix4().copy(localToSegment).invert() + result.applyMatrix4(inverse) + result.computeVertexNormals() + // CSG can stamp the freshly-exposed cut faces with the cutter's material + // slot, which may exceed the accessory's material array (multi-slot kinds: + // dormer = 5 slots, solar-panel / skylight = 2). Clamp every group back into + // the original geometry's slot range so the renderer never indexes past its + // `material` array (mismatch crashes the draw, as the empty-segment + // placeholder guard documents). + const maxSlot = geometry.groups.reduce((m, g) => Math.max(m, g.materialIndex ?? 0), 0) + for (const g of result.groups) { + if ((g.materialIndex ?? 0) > maxSlot) g.materialIndex = maxSlot + } + return result + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [geometry, localToSegment, effectiveSegment]) + + useEffect(() => { + return () => { + clipped?.dispose() + } + }, [clipped]) + + // No trim (or no input) → render the original geometry unchanged. + return clipped ?? geometry +} + +const _trimMeshLocal = new THREE.Matrix4() +const _trimMeshPos = new THREE.Vector3() +const _trimMeshQuat = new THREE.Quaternion() +const _trimMeshEuler = new THREE.Euler() +const _trimMeshScale = new THREE.Vector3(1, 1, 1) + +/** + * A `` whose geometry is sliced by the host roof segment's trim, for + * accessory sub-parts that live deeper than the registered group (skylight + * glass panes, dormer window glass / frame / sill). Pass the matrix that maps + * the part's own parent frame into the segment-local frame (`parentToSegment`) + * plus the part's local `position` / `rotation`; the component composes the + * full mesh→segment transform, clips, and renders the result at the same local + * pose. When the segment has no trim the original geometry renders unchanged. + * + * `geometry` is owned by the caller (built once and reused); the clipped + * derivative is owned by the hook and disposed on change / unmount. + */ +export function TrimClippedMesh({ + geometry, + segment, + parentToSegment, + position = [0, 0, 0], + rotation = [0, 0, 0], + material, + name, + castShadow, + receiveShadow, +}: { + geometry: THREE.BufferGeometry + segment: RoofSegmentNode | undefined + parentToSegment: THREE.Matrix4 + position?: [number, number, number] + rotation?: [number, number, number] + material: THREE.Material | THREE.Material[] + name?: string + castShadow?: boolean + receiveShadow?: boolean +}) { + // mesh→segment = parentToSegment · T(position) · R(rotation). Keyed in the + // hook on the matrix elements, so a moving/animated pane re-clips correctly. + const localToSegment = useMemo(() => { + _trimMeshEuler.set(rotation[0], rotation[1], rotation[2]) + _trimMeshQuat.setFromEuler(_trimMeshEuler) + _trimMeshPos.set(position[0], position[1], position[2]) + _trimMeshLocal.compose(_trimMeshPos, _trimMeshQuat, _trimMeshScale) + return new Matrix4().multiplyMatrices(parentToSegment, _trimMeshLocal) + }, [ + parentToSegment, + position[0], + position[1], + position[2], + rotation[0], + rotation[1], + rotation[2], + ]) + + const clipped = useSegmentTrimClippedGeometry(geometry, segment, localToSegment) + + return ( + + ) +} diff --git a/packages/nodes/src/shared/vertical-offset.ts b/packages/nodes/src/shared/vertical-offset.ts index 54dd96bfc..2ea4aa3d6 100644 --- a/packages/nodes/src/shared/vertical-offset.ts +++ b/packages/nodes/src/shared/vertical-offset.ts @@ -590,7 +590,7 @@ function directRiserCollapseAction(args: { ) if (!farPort) return null const lower = nodesById[farPort.nodeId] - if (!lower || lower.type !== 'duct-fitting') return null + if (lower?.type !== 'duct-fitting') return null const lowerElbow = { ...(lower as DuctFittingNode), ...profilePatch } as DuctFittingNode if (lowerElbow.fittingType !== 'elbow') return null @@ -687,7 +687,7 @@ function fittingRiserAction(args: { continue } const lower = nodesById[farPort.nodeId] - if (!lower || lower.type !== 'duct-fitting') { + if (lower?.type !== 'duct-fitting') { if (stretch === 'stretch') return { status: 'stretch' } continue } @@ -828,7 +828,7 @@ function planVerticalOffsetsAtDy( // shows a red preview rather than dragging the network up to absorb it). if (conn.kind !== 'run') { const partner = nodesById[conn.nodeId] - if (!partner || partner.type !== 'duct-fitting') return { status: 'invalid' } + if (partner?.type !== 'duct-fitting') return { status: 'invalid' } const elbow = partner as DuctFittingNode const profilePatch = elbowProfilePatch(profile) const profiledElbow = { ...elbow, ...profilePatch } as DuctFittingNode diff --git a/packages/nodes/src/skylight/floorplan.ts b/packages/nodes/src/skylight/floorplan.ts index 2c4bf4cc3..0a2be7123 100644 --- a/packages/nodes/src/skylight/floorplan.ts +++ b/packages/nodes/src/skylight/floorplan.ts @@ -26,10 +26,10 @@ export function buildSkylightFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null const cosR = Math.cos(-roof.rotation) const sinR = Math.sin(-roof.rotation) diff --git a/packages/nodes/src/skylight/panel.tsx b/packages/nodes/src/skylight/panel.tsx index 2eaba0cb5..1b54bba86 100644 --- a/packages/nodes/src/skylight/panel.tsx +++ b/packages/nodes/src/skylight/panel.tsx @@ -142,7 +142,7 @@ export default function SkylightPanel() { const state = useScene.getState() const worldPt = new Vector3(wx, 0, wz) for (const candidate of Object.values(state.nodes)) { - if (!candidate || candidate.type !== 'roof-segment') continue + if (candidate?.type !== 'roof-segment') continue const seg = candidate as RoofSegmentNode const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) continue diff --git a/packages/nodes/src/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index f4038470f..f6fe5f7ff 100644 --- a/packages/nodes/src/skylight/renderer.tsx +++ b/packages/nodes/src/skylight/renderer.tsx @@ -23,6 +23,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { surfaceQuatFromNormal } from '../shared/roof-surface' +import { TrimClippedMesh, useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildFrameGeometry } from './frame-csg' import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry' @@ -99,6 +100,8 @@ function GlassPane({ position = [0, 0, 0], rotation, width, + segment, + parentToSegment, }: { glassThickness: number material: THREE.Material | THREE.Material[] @@ -107,11 +110,42 @@ function GlassPane({ position?: [number, number, number] rotation?: [number, number, number] width: number + // Trim-clip context: when provided, the glass pane is sliced by the host + // segment's trim (so the glazing matches the roof cutaway). Absent in + // contexts with no host segment frame. + segment?: RoofSegmentNode + parentToSegment?: THREE.Matrix4 }) { + const geometry = useMemo( + () => new THREE.BoxGeometry(paneSize(width), paneSize(glassThickness), paneSize(paneDepth)), + [width, glassThickness, paneDepth], + ) + useEffect(() => () => geometry.dispose(), [geometry]) + + if (segment && parentToSegment) { + return ( + + ) + } + return ( - - - + ) } @@ -181,11 +215,15 @@ function LanternGlass({ frameMaterial, glassMaterial, node, + segment, + parentToSegment, }: { curbHeight: number frameMaterial: THREE.Material | THREE.Material[] glassMaterial: THREE.Material | THREE.Material[] node: SkylightNode + segment?: RoofSegmentNode + parentToSegment?: THREE.Matrix4 }) { const preset = SKYLIGHT_TYPE_PRESETS.lantern const width = node.width - 0.01 @@ -228,9 +266,27 @@ function LanternGlass({ } }, [geometry]) + const glassParentToSegment = + segment && parentToSegment + ? new THREE.Matrix4() + .copy(parentToSegment) + .multiply(new THREE.Matrix4().makeTranslation(0, curbHeight, 0)) + : undefined + return ( - + {segment && glassParentToSegment ? ( + + ) : ( + + )} {baseCorners.map((corner, index) => ( { const hasCurb = node.curb ?? false const curbH = hasCurb ? Math.max(0, node.curbHeight ?? 0.1) : 0 + // Map skylight-local geometry into the host segment's local frame (where the + // trim cut prisms live) — the same pose the inner registered group is mounted + // with (position [x, surfaceY, z] + surfaceQuat·yaw). Only the structural + // frame is clipped; the thin glass panes live inside animated sub-components + // (sliding / hinged) with their own internal transforms and ride with the + // frame. Computed before the early return so the hook order stays stable. + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, surfaceFrame.point.y, node.position[2] ?? 0), + composedQuat, + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[2], surfaceFrame.point.y, composedQuat], + ) + const clippedFrame = useSegmentTrimClippedGeometry(frameGeo, segment, localToSegment) + if (!segment || !frameGeo) return null const surfaceY = surfaceFrame.point.y @@ -707,7 +823,7 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { > { frameMaterial={frameMaterial} glassMaterial={glassMaterial} node={node} + parentToSegment={localToSegment} + segment={segment} /> )} {activeType === 'sliding' && ( @@ -728,6 +846,8 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { glassThickness={glassThickness} node={node} openAmount={openAmount} + parentToSegment={localToSegment} + segment={segment} /> )} {activeType === 'opening' && ( @@ -739,6 +859,8 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { hasMotorHousing={node.motorHousing ?? false} node={node} openAmount={openAmount} + parentToSegment={localToSegment} + segment={segment} /> )} {(activeType === 'flat' || activeType === 'walk-on') && ( @@ -746,7 +868,9 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { glassThickness={glassThickness} material={glassMaterial} paneDepth={node.height + 0.004} + parentToSegment={localToSegment} position={[0, curbH + glassThickness / 2, 0]} + segment={segment} width={node.width + 0.004} /> )} diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index db9594e66..42e8afb6e 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -20,6 +20,7 @@ import { getSegmentGridStep, isMagneticSnapActive, markToolCancelConsumed, + projectAlignmentGuidesWorldToActiveBuildingLocal, resolveAlignmentForActiveBuilding, snapBuildingLocalToWorldGrid, snapFenceDraftPoint, @@ -205,7 +206,9 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { deltaX += result.snap.dx deltaZ += result.snap.dz } - useAlignmentGuides.getState().set(result.guides) + useAlignmentGuides + .getState() + .set(projectAlignmentGuidesWorldToActiveBuildingLocal(result.guides)) } else { useAlignmentGuides.getState().clear() } diff --git a/packages/nodes/src/solar-panel/__tests__/geometry.test.ts b/packages/nodes/src/solar-panel/__tests__/geometry.test.ts index deb6328bd..f617c50a0 100644 --- a/packages/nodes/src/solar-panel/__tests__/geometry.test.ts +++ b/packages/nodes/src/solar-panel/__tests__/geometry.test.ts @@ -135,9 +135,7 @@ describe('getAnalyticalNormal', () => { expect(top.x).toBeGreaterThan(0) expect(top.x).toBeLessThan(steep.x) }) - // Regression: dutch previously fell through to gable code, ignoring - // the X axis. Hip ends rendered with the wrong tilt direction. - test('dutch +x hip end tilts toward +x (w>=d)', () => { + test('dutch width-axis shoulder tilts toward +x when outside the waist span', () => { const seg = fixtureSegment({ roofType: 'dutch', width: 8, depth: 6 }) const n = getAnalyticalNormal(seg.width / 2 - 0.01, 0, seg) expect(n.x).toBeGreaterThan(0) @@ -151,6 +149,18 @@ describe('getAnalyticalNormal', () => { expect(Math.abs(n.x)).toBeLessThan(1e-6) expect(n.y).toBeGreaterThan(0) }) + test('dutch top gable face keeps the same fall direction near the ridge', () => { + const seg = fixtureSegment({ + roofType: 'dutch', + width: 8, + depth: 6, + dutchHipWidthRatio: 0.2, + dutchHipHeightRatio: 0.6, + }) + const upper = getAnalyticalNormal(0, 0.01, seg) + expect(upper.z).toBeGreaterThan(0) + expect(Math.abs(upper.x)).toBeLessThan(1e-6) + }) }) describe('computeAutoFit', () => { diff --git a/packages/nodes/src/solar-panel/floorplan.ts b/packages/nodes/src/solar-panel/floorplan.ts index dd305cf90..ac640d990 100644 --- a/packages/nodes/src/solar-panel/floorplan.ts +++ b/packages/nodes/src/solar-panel/floorplan.ts @@ -31,10 +31,10 @@ export function buildSolarPanelFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null // Compose roof → segment → panel in plan coords. Each rotation is // negated so SVG's y-down CW matches Three.js' top-down CCW. diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index 997c29b00..f92112994 100644 --- a/packages/nodes/src/solar-panel/renderer.tsx +++ b/packages/nodes/src/solar-panel/renderer.tsx @@ -21,6 +21,7 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { MeshStandardNodeMaterial } from 'three/webgpu' import { surfaceQuatFromNormal } from '../shared/roof-surface' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildSolarPanelGeometry, getDefaultPanelMaterial } from './geometry' // Module-scope scratch vectors and quaternions for composing the panel's @@ -156,6 +157,34 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { [surfaceFrame.normal], ) + // Map panel-local geometry into the host segment's local frame (where the + // trim cut prisms live). Recompose the same pose the inner mesh group is + // mounted with (position [x, surfaceY, z] + surfaceQuat·yaw·tilt) so the clip + // matches the rendered placement exactly. Computed before the early return so + // the hook order stays stable. + const localToSegment = useMemo(() => { + const surfaceY = surfaceFrame.point.y + const tiltRad = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0 + const quat = new THREE.Quaternion() + .copy(surfaceQuat) + .multiply(new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0)) + .multiply(new THREE.Quaternion().setFromAxisAngle(xAxis, tiltRad)) + return new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, surfaceY, node.position[2] ?? 0), + quat, + new THREE.Vector3(1, 1, 1), + ) + }, [ + surfaceFrame.point.y, + surfaceQuat, + node.mountingType, + node.tiltAngle, + node.rotation, + node.position[0], + node.position[2], + ]) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment) + if (!effectiveSegment || !geometry) return null const surfaceY = surfaceFrame.point.y @@ -190,7 +219,7 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { > { if (!node?.parentId) return true const parent = s.nodes[node.parentId as AnyNodeId] - if (!parent || parent.type !== 'stair') return true + if (parent?.type !== 'stair') return true const children = (parent as any).children ?? [] return children[0] === node.id }) diff --git a/packages/nodes/src/stair/floorplan-affordances.ts b/packages/nodes/src/stair/floorplan-affordances.ts index f4dbe9de7..905455320 100644 --- a/packages/nodes/src/stair/floorplan-affordances.ts +++ b/packages/nodes/src/stair/floorplan-affordances.ts @@ -57,7 +57,7 @@ export const segmentWidthAffordance: FloorplanAffordance = { const { segmentId, side, axisX } = payload as SegmentWidthPayload const segmentNodeId = segmentId as AnyNodeId const segment = nodes[segmentNodeId] as StairSegmentNode | undefined - if (!segment || segment.type !== 'stair-segment') return noopSession() + if (segment?.type !== 'stair-segment') return noopSession() const initialWidth = segment.width const sign = side === 'right' ? 1 : -1 @@ -96,7 +96,7 @@ export const segmentLengthAffordance: FloorplanAffordance = { const { segmentId, axisZ } = payload as SegmentLengthPayload const segmentNodeId = segmentId as AnyNodeId const segment = nodes[segmentNodeId] as StairSegmentNode | undefined - if (!segment || segment.type !== 'stair-segment') return noopSession() + if (segment?.type !== 'stair-segment') return noopSession() const initialLength = segment.length const az = axisZ[0] diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 13d6e6f21..00297893a 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -309,6 +309,33 @@ export default function StairPanel() { value={Math.round((node.openingOffset ?? 0) * 100) / 100} /> ) : null} + + {node.stairType === 'spiral' && ( + <> +
+
+ Landing +
+ handleUpdate({ topLandingMode: value })} + options={TOP_LANDING_MODE_OPTIONS} + value={node.topLandingMode ?? 'none'} + /> +
+ {(node.topLandingMode ?? 'none') === 'integrated' && ( + handleUpdate({ topLandingDepth: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100} + /> + )} + + )}
@@ -415,23 +442,6 @@ export default function StairPanel() { /> {node.stairType === 'spiral' && ( <> - handleUpdate({ topLandingMode: value })} - options={TOP_LANDING_MODE_OPTIONS} - value={node.topLandingMode ?? 'none'} - /> - {(node.topLandingMode ?? 'none') === 'integrated' && ( - handleUpdate({ topLandingDepth: value })} - precision={2} - step={0.05} - unit="m" - value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100} - /> - )} return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat) }, [surfaceQuat, node.rotation, yAxis]) + const neckHForClip = Math.max( + 0.02, + Math.min(node.neckHeight ?? 0.09, Math.max(0.12, node.height) * 0.5), + ) + // Map vent-local geometry into the host segment's local frame (where the trim + // cut prisms live). The base sits at the inner group's pose; the head is + // nested one level deeper at [0, neckH, 0], so its clip matrix composes that + // offset. (When the head is spinning — opt-in, default paused — the cut edge + // rotates with it, which is acceptable for the animation.) + const baseLocalToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0), + composedQuat, + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[1], node.position[2], composedQuat], + ) + const headLocalToSegment = useMemo( + () => + new THREE.Matrix4() + .copy(baseLocalToSegment) + .multiply(new THREE.Matrix4().makeTranslation(0, neckHForClip, 0)), + [baseLocalToSegment, neckHForClip], + ) + const clippedBase = useSegmentTrimClippedGeometry(baseGeometry, segment, baseLocalToSegment) + const clippedHead = useSegmentTrimClippedGeometry(headGeometry, segment, headLocalToSegment) + if (!segment) return null // Replicate the parent segment's roof-local transform — see the long @@ -133,7 +162,7 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => > = ({ node }) canCommit() { const live = useScene.getState().nodes[wallId] as WallNode | undefined - if (!live || live.type !== 'wall') return false + if (live?.type !== 'wall') return false const [dx, dz] = lastDelta return dx !== 0 || dz !== 0 }, @@ -244,7 +244,7 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) commit() { const sceneState = useScene.getState() const liveWall = sceneState.nodes[wallId] as WallNode | undefined - if (!liveWall || liveWall.type !== 'wall') { + if (liveWall?.type !== 'wall') { // Bail without leaving stale overrides behind. const overrides = useLiveNodeOverrides.getState() overrides.clear(wallId) diff --git a/packages/nodes/src/wall/floorplan-overrides.ts b/packages/nodes/src/wall/floorplan-overrides.ts index 72c6804b0..d069d152e 100644 --- a/packages/nodes/src/wall/floorplan-overrides.ts +++ b/packages/nodes/src/wall/floorplan-overrides.ts @@ -25,7 +25,7 @@ export function wallFloorplanSiblingOverrides(args: { let out: Record | null = null for (const [id, override] of liveOverrides) { const existing = nodes[id as AnyNodeId] - if (!existing || existing.type !== 'wall') continue + if (existing?.type !== 'wall') continue if (Object.keys(override).length === 0) continue if (!out) out = { ...nodes } out[id as AnyNodeId] = { ...existing, ...override } as AnyNode diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 3f57359ec..f7537daeb 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -1,4 +1,5 @@ import { + type AnyNode, calculateLevelMiters, collectAlignmentAnchors, emitter, @@ -7,6 +8,7 @@ import { type LevelNode, type Point2D, resolveAlignment, + resolveBuildingForLevel, useScene, type WallMiterData, type WallNode, @@ -464,15 +466,43 @@ function updateWallPreview( mesh.geometry = geometry } +function getLevelWalls(levelId: string | null, nodes: Record): WallNode[] { + if (!levelId) return [] + const levelNode = nodes[levelId] + if (levelNode?.type !== 'level') return [] + return (levelNode as LevelNode).children + .map((childId) => nodes[childId]) + .filter((node): node is WallNode => node?.type === 'wall') +} + function getCurrentLevelWalls(): WallNode[] { + const currentLevelId = useViewer.getState().selection.levelId + const { nodes } = useScene.getState() + return getLevelWalls(currentLevelId ?? null, nodes) +} + +// Walls on the level directly beneath the active one. Levels share the same +// local XZ origin (they only differ in world Y), so these walls live in the +// identical coordinate frame and can be fed straight into the snap pipeline — +// letting the user draw a new wall aligned with the floor below. They are +// snap references only; `createWallOnCurrentLevel` re-derives its own +// current-level wall list, so the floor below is never split or mutated. +function getBelowLevelWalls(): WallNode[] { const currentLevelId = useViewer.getState().selection.levelId const { nodes } = useScene.getState() if (!currentLevelId) return [] - const levelNode = nodes[currentLevelId] - if (!levelNode || levelNode.type !== 'level') return [] - return (levelNode as LevelNode).children + const currentLevel = nodes[currentLevelId] + if (currentLevel?.type !== 'level') return [] + const buildingId = resolveBuildingForLevel(currentLevelId, nodes) + if (!buildingId) return [] + const building = nodes[buildingId] + if (building?.type !== 'building') return [] + const currentIndex = (currentLevel as LevelNode).level + const belowLevel = (building.children ?? []) .map((childId) => nodes[childId]) - .filter((node): node is WallNode => node?.type === 'wall') + .filter((node): node is LevelNode => node?.type === 'level' && node.level < currentIndex) + .sort((a, b) => b.level - a.level)[0] + return getLevelWalls(belowLevel?.id ?? null, nodes) } export const WallTool: React.FC = () => { @@ -558,6 +588,10 @@ export const WallTool: React.FC = () => { if (!(cursorRef.current && wallPreviewRef.current)) return const walls = getCurrentLevelWalls() + // Add walls on the floor below as extra snap references so the new wall + // can align with the level beneath it. Kept separate from `walls` so the + // measurement HUD only reports against the active level. + const snapWalls = [...walls, ...getBelowLevelWalls()] const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] // Snapping is governed entirely by the snapping mode (grid / lines / // angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass. @@ -566,7 +600,7 @@ export const WallTool: React.FC = () => { const bypassAlign = !isMagneticSnapActive() const snapResult = snapWallDraftPointDetailed({ point: localPoint, - walls, + walls: snapWalls, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, angleSnap: angleLocked, magnetic: isMagneticSnapActive(), @@ -641,6 +675,7 @@ export const WallTool: React.FC = () => { } const walls = getCurrentLevelWalls() + const snapWalls = [...walls, ...getBelowLevelWalls()] const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] // Alignment guides follow the snapping mode (lines = magnetic on), not Alt. @@ -650,7 +685,7 @@ export const WallTool: React.FC = () => { const snappedStart = alignPoint( snapWallDraftPointDetailed({ point: localClick, - walls, + walls: snapWalls, magnetic: isMagneticSnapActive(), }).point, { bypass: bypassAlign }, @@ -679,7 +714,7 @@ export const WallTool: React.FC = () => { const snappedEnd = alignPoint( snapWallDraftPointDetailed({ point: localClick, - walls, + walls: snapWalls, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, angleSnap: angleLocked, magnetic: isMagneticSnapActive(), diff --git a/packages/nodes/src/window/floorplan.ts b/packages/nodes/src/window/floorplan.ts index 7468d2bf2..cb37e7172 100644 --- a/packages/nodes/src/window/floorplan.ts +++ b/packages/nodes/src/window/floorplan.ts @@ -25,7 +25,7 @@ export function buildWindowFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const wall = ctx.parent as WallNode | null - if (!wall || wall.type !== 'wall') return null + if (wall?.type !== 'wall') return null const [x1, z1] = wall.start const [x2, z2] = wall.end diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index 50c835429..61b1728a8 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -175,11 +175,8 @@ function buildGlbColliderWorld(scene: Object3D): GlbColliderWorld | null { merged?.dispose() return null } - // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype ;(merged as any).computeBoundsTree = computeBoundsTree - // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype ;(merged as any).disposeBoundsTree = disposeBoundsTree - // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension ;(merged as any).computeBoundsTree({ maxLeafSize: 12, strategy: 0 }) merged.computeBoundingBox() @@ -199,7 +196,6 @@ function buildGlbColliderWorld(scene: Object3D): GlbColliderWorld | null { mesh, minY: merged.boundingBox?.min.y ?? 0, dispose: () => { - // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension ;(merged as any).disposeBoundsTree?.() merged.dispose() }, diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 761e4ce54..5dc8e3b2f 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -53,6 +53,7 @@ export { csgEvaluator, csgGeometry, csgMaterials, + INTERSECTION, prepareBrushForCSG, SUBTRACTION, } from './lib/csg-utils' @@ -141,6 +142,8 @@ export { getRoofMaterialArray } from './systems/roof/roof-materials' // read these through the public surface. No kind-specific helpers // belong here — those live in `@pascal-app/nodes//`. export { + clipGeometryBySegmentTrim, + generateRoofSegmentGeometry, getRoofOuterSurfaceFrameAtPoint, getRoofSegmentBrushes, mapRoofGroupMaterialIndex, diff --git a/packages/viewer/src/lib/csg-utils.ts b/packages/viewer/src/lib/csg-utils.ts index 982b3a022..a59e900eb 100644 --- a/packages/viewer/src/lib/csg-utils.ts +++ b/packages/viewer/src/lib/csg-utils.ts @@ -111,6 +111,6 @@ export function prepareBrushForCSG(brush: Brush) { brush.updateMatrixWorld() } -// Re-export Brush + SUBTRACTION + ADDITION so kinds don't need a direct -// `three-bvh-csg` dependency. -export { ADDITION, Brush, SUBTRACTION } from 'three-bvh-csg' +// Re-export Brush + SUBTRACTION + ADDITION + INTERSECTION so kinds don't need a +// direct `three-bvh-csg` dependency. +export { ADDITION, Brush, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 3b5fd63c5..b1fc6623c 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -35,7 +35,7 @@ export const CeilingSystem = () => { // Process dirty ceilings dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'ceiling') return + if (node?.type !== 'ceiling') return const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (mesh) { diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 186243f83..28a0e4032 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -139,7 +139,7 @@ export const DoorSystem = () => { dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'door') return + if (node?.type !== 'door') return dirtyDoorIds.push(id as AnyNodeId) }) @@ -161,7 +161,7 @@ export const DoorSystem = () => { } const node = nodes[id] - if (!node || node.type !== 'door') continue + if (node?.type !== 'door') continue const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (!mesh) continue // Keep dirty until mesh mounts diff --git a/packages/viewer/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx index f60ca60e1..180076b6e 100644 --- a/packages/viewer/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -1,8 +1,8 @@ import { type AnyNodeId, type FenceNode, - getWallCurveFrameAt, - getWallCurveLength, + getFenceCenterlineFrameAt, + getFenceCenterlineLength, sceneRegistry, useScene, } from '@pascal-app/core' @@ -11,6 +11,7 @@ import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' type FencePart = { + geometry?: THREE.BufferGeometry position: [number, number, number] rotationY?: number scale: [number, number, number] @@ -20,8 +21,12 @@ type FencePart = { } const MIN_CURVE_SEGMENT_LENGTH = 0.18 +const HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH = 0.2 function createFencePartGeometry(part: FencePart) { + if (part.geometry) { + return part.geometry + } const geometry = part.shape === 'pyramid' ? new THREE.ConeGeometry(0.5, 1, 4, 1, false, Math.PI / 4) @@ -36,65 +41,164 @@ function createFencePartGeometry(part: FencePart) { } function getFencePointAt(fence: FenceNode, t: number) { - const frame = getWallCurveFrameAt(fence, t) + const frame = getFenceCenterlineFrameAt(fence, t) return { point: frame.point, tangentAngle: Math.atan2(frame.tangent.y, frame.tangent.x), } } -function createStraightFenceSpanPart( - start: [number, number], - end: [number, number], +function createFenceCurveBlockPart( + fence: FenceNode, + startT: number, + endT: number, centerY: number, height: number, depth: number, ): FencePart | null { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const length = Math.hypot(dx, dz) - if (length <= 1e-4) { - return null + if (endT - startT <= 1e-5) return null + const halfHeight = height / 2 + const halfDepth = depth / 2 + const centerlineLength = getFenceCenterlineLength(fence) + const startDistance = startT * centerlineLength + const endDistance = endT * centerlineLength + const bottomY = centerY - halfHeight + const topY = centerY + halfHeight + const corners: Array<[number, number, number]> = [] + + for (const t of [startT, endT]) { + const frame = getFencePointAt(fence, t) + const normalX = -Math.sin(frame.tangentAngle) + const normalZ = Math.cos(frame.tangentAngle) + + const outerX = frame.point.x + normalX * halfDepth + const outerZ = frame.point.y + normalZ * halfDepth + const innerX = frame.point.x - normalX * halfDepth + const innerZ = frame.point.y - normalZ * halfDepth + + corners.push( + [outerX, bottomY, outerZ], + [innerX, bottomY, innerZ], + [outerX, topY, outerZ], + [innerX, topY, innerZ], + ) + } + + const positions: number[] = [] + const uvs: number[] = [] + const pushVertex = (index: number, uv: [number, number]) => { + positions.push(...corners[index]!) + uvs.push(...uv) + } + + const pushQuad = ( + a: number, + b: number, + c: number, + d: number, + uvA: [number, number], + uvB: [number, number], + uvC: [number, number], + uvD: [number, number], + ) => { + pushVertex(a, uvA) + pushVertex(b, uvB) + pushVertex(c, uvC) + pushVertex(a, uvA) + pushVertex(c, uvC) + pushVertex(d, uvD) } + const topOuterV = topY + const topInnerV = topY + depth + const innerTopV = topInnerV + const innerBottomV = topInnerV + height + const bottomInnerV = bottomY - depth + + pushQuad( + 0, + 4, + 6, + 2, + [startDistance, bottomY], + [endDistance, bottomY], + [endDistance, topY], + [startDistance, topY], + ) + pushQuad( + 1, + 3, + 7, + 5, + [startDistance, innerBottomV], + [startDistance, innerTopV], + [endDistance, innerTopV], + [endDistance, innerBottomV], + ) + pushQuad( + 2, + 6, + 7, + 3, + [startDistance, topOuterV], + [endDistance, topOuterV], + [endDistance, topInnerV], + [startDistance, topInnerV], + ) + pushQuad( + 0, + 1, + 5, + 4, + [startDistance, bottomY], + [startDistance, bottomInnerV], + [endDistance, bottomInnerV], + [endDistance, bottomY], + ) + pushQuad(0, 2, 3, 1, [0, bottomY], [0, topY], [depth, innerTopV], [depth, innerBottomV]) + pushQuad(4, 5, 7, 6, [0, bottomY], [depth, innerBottomV], [depth, innerTopV], [0, topY]) + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array(positions), 3), + ) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(uvs), 2)) + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(uvs), 2)) + geometry.computeVertexNormals() + return { - position: [(start[0] + end[0]) / 2, centerY, (start[1] + end[1]) / 2], - rotationY: -Math.atan2(dz, dx), - scale: [length, height, depth], + geometry, + position: [0, 0, 0], + scale: [1, 1, 1], } } -function createFenceCurveSpanParts( +function createFenceCurveBlockParts( fence: FenceNode, startT: number, endT: number, centerY: number, height: number, depth: number, + maxSegmentLength = MIN_CURVE_SEGMENT_LENGTH, ): FencePart[] { + const length = getFenceCenterlineLength(fence) * Math.max(1e-4, endT - startT) + const segmentCount = Math.max(1, Math.ceil(length / Math.max(1e-4, maxSegmentLength))) const parts: FencePart[] = [] - const frameCount = Math.max( - 1, - Math.ceil( - (getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH, - ), - ) - let previous = getFencePointAt(fence, startT) - for (let index = 1; index <= frameCount; index += 1) { - const t = startT + (endT - startT) * (index / frameCount) - const current = getFencePointAt(fence, t) - const segment = createStraightFenceSpanPart( - [previous.point.x, previous.point.y], - [current.point.x, current.point.y], + for (let index = 0; index < segmentCount; index += 1) { + const segmentStartT = startT + (endT - startT) * (index / segmentCount) + const segmentEndT = startT + (endT - startT) * ((index + 1) / segmentCount) + const part = createFenceCurveBlockPart( + fence, + segmentStartT, + segmentEndT, centerY, height, depth, ) - if (segment) { - parts.push(segment) - } - previous = current + if (part) parts.push(part) } return parts @@ -176,7 +280,7 @@ function createHorizontalFenceParts(fence: FenceNode): FenceSlotParts { const base: FencePart[] = [] const rail: FencePart[] = [] - const length = Math.max(getWallCurveLength(fence), 0.01) + const length = Math.max(getFenceCenterlineLength(fence), 0.01) const panelDepth = Math.max(fence.thickness, 0.03) const clearance = Math.max(fence.groundClearance, 0) const isFloating = fence.baseStyle === 'floating' @@ -191,17 +295,25 @@ function createHorizontalFenceParts(fence: FenceNode): FenceSlotParts { const postWidth = Math.max(fence.postSize * 1.4, 0.04) const postDepth = postWidth const boardDepth = Math.min(panelDepth, postDepth - 0.012) + // Stop the horizontal boards / base / rail at the inner faces of the + // end posts. Letting curved spans run all the way to t=0/1 makes them + // overlap the terminal post mesh and creates the broken seam/notch seen + // at curve ends. + const edgeInset = Math.max(fence.edgeInset ?? 0.015, postWidth * 0.5) + const startInsetT = Math.min(0.499, edgeInset / length) + const endInsetT = Math.max(0.501, 1 - edgeInset / length) // Grounded fences get a kickboard along the bottom; floating ones don't. if (!isFloating) { base.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, - 0, - 1, + startInsetT, + endInsetT, baseY + baseHeight / 2, baseHeight, postDepth * 0.92, + HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH, ), ) } @@ -216,13 +328,14 @@ function createHorizontalFenceParts(fence: FenceNode): FenceSlotParts { // No reveal → one flush panel, so the stacked-board edge seams don't // read as faint lines where the user asked for a smooth surface. infill.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, - 0, - 1, + startInsetT, + endInsetT, infillBottom + verticalHeight / 2, verticalHeight, boardDepth, + HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH, ), ) } else { @@ -230,20 +343,31 @@ function createHorizontalFenceParts(fence: FenceNode): FenceSlotParts { const slabHeight = Math.max((verticalHeight - reveal * (boardCount - 1)) / boardCount, 0.02) for (let index = 0; index < boardCount; index += 1) { const centerY = infillBottom + slabHeight / 2 + index * (slabHeight + reveal) - infill.push(...createFenceCurveSpanParts(fence, 0, 1, centerY, slabHeight, boardDepth)) + infill.push( + ...createFenceCurveBlockParts( + fence, + startInsetT, + endInsetT, + centerY, + slabHeight, + boardDepth, + HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH, + ), + ) } } } // Top rail caps the boards. rail.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, - 0, - 1, + startInsetT, + endInsetT, baseY + baseHeight + verticalHeight + topRailHeight / 2, topRailHeight, Math.max(postDepth * 0.78, 0.02), + HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH, ), ) @@ -287,7 +411,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { const infill: FencePart[] = [] const base: FencePart[] = [] const rail: FencePart[] = [] - const length = Math.max(getWallCurveLength(fence), 0.01) + const length = Math.max(getFenceCenterlineLength(fence), 0.01) const panelDepth = Math.max(fence.thickness, 0.03) const clearance = Math.max(fence.groundClearance, 0) const styleDefaults = getStyleDefaults(fence.style) @@ -306,7 +430,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { if (!isFloating) { base.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, 0, 1, @@ -315,8 +439,9 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { panelDepth * 1.05, ), ) + base.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, 0, 1, @@ -332,7 +457,6 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { for (let index = 0; index < count; index += 1) { const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1)) - const frame = getFencePointAt(fence, t) const isEdgePost = index === 0 || index === count - 1 const fullHeightPost = !showInfill || (isFloating && isEdgePost) const postHeight = fullHeightPost @@ -342,17 +466,24 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { // End posts are the structural `posts` slot; the intermediate verticals are // the `infill` slats (only present when showInfill adds them). - // Depth is 0.001 m shy of the accent rail's `panelDepth * 0.35` so the two - // never share a coplanar face where they cross (kills the rail z-fighting). - ;(isEdgePost ? posts : infill).push({ - position: [frame.point.x, postY, frame.point.y], - rotationY: -frame.tangentAngle, - scale: [postWidth, postHeight, Math.max(panelDepth * 0.35 - 0.001, 0.011)], - }) + const slatHalfT = Math.max(0.0005, postWidth / (2 * length)) + const slatStartT = Math.max(0, t - slatHalfT) + const slatEndT = Math.min(1, t + slatHalfT) + const slat = createFenceCurveBlockPart( + fence, + slatStartT, + slatEndT, + postY, + postHeight, + Math.max(panelDepth * 0.35 - 0.001, 0.011), + ) + if (slat) { + ;(isEdgePost ? posts : infill).push(slat) + } } rail.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, 0, 1, @@ -364,7 +495,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { if (isFloating) { rail.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, 0, 1, @@ -421,7 +552,7 @@ export function generateFenceGeometry(fence: FenceNode) { function updateFenceGeometry(fenceId: FenceNode['id']) { const node = useScene.getState().nodes[fenceId] - if (!node || node.type !== 'fence') return + if (node?.type !== 'fence') return const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Mesh | undefined if (!mesh) return @@ -443,7 +574,7 @@ export const FenceSystem = () => { const nodes = useScene.getState().nodes dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'fence') return + if (node?.type !== 'fence') return updateFenceGeometry(id as FenceNode['id']) clearDirty(id as AnyNodeId) }) diff --git a/packages/viewer/src/systems/item/item-system.tsx b/packages/viewer/src/systems/item/item-system.tsx index 25cb03b4b..0458437ad 100644 --- a/packages/viewer/src/systems/item/item-system.tsx +++ b/packages/viewer/src/systems/item/item-system.tsx @@ -29,7 +29,7 @@ export const ItemSystem = () => { dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'item') return + if (node?.type !== 'item') return const item = node as ItemNode const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 637af8bca..3a65665a2 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1,10 +1,17 @@ import { type AnyNode, type AnyNodeId, + getDutchEndSlopeFaces, + getDutchRoofShapeMetrics, getEffectiveNode, + getRoofModuleFaces, + getRoofShapeInsets, + getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, nodeRegistry, + normalizeRoofSegmentTrim, + ROOF_SHAPE_DEFAULTS, type RoofNode, type RoofSegmentNode, type RoofType, @@ -14,7 +21,7 @@ import { } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import * as THREE from 'three' -import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' @@ -143,6 +150,10 @@ export const RoofSystem = () => { if (rootNodeIds.length === 0) { pendingRoofUpdates.clear() warnedMergedRoofNaNIds.clear() + for (const cached of mergedRoofSegmentGeometryCache.values()) { + disposeCachedMergedRoofSegmentGeometrySet(cached) + } + mergedRoofSegmentGeometryCache.clear() return } @@ -156,25 +167,19 @@ export const RoofSystem = () => { const node = nodes[id] if (!node) return - // Roof accessories (chimney, skylight, solar-panel, dormer, - // ridge-vent, box-vent — anything declaring - // `capabilities.roofAccessory` on its NodeDefinition) cascade - // their dirty mark to the host segment's parent roof so the - // merged shell re-CSGs with the new cut. Without this, moving / - // resizing an accessory leaves the merged roof showing the - // previous cut shape (stale CSG) once the user exits segment - // edit mode. Registry-driven so the viewer stays kind-agnostic. + // Cutting roof accessories cascade their dirty mark to the host + // segment's parent roof so the merged shell re-CSGs with the new + // cut. Non-cutting accessories (vents, panels, gutters, etc.) sit on + // top of the shell and should not force a full roof merge. const def = nodeRegistry.get(node.type) // Kinds with `dirtyHandledByOwnSystem` (door / window) reach the roof // through their own geometry system's parentId cascade instead — // their dirty marks belong to that system, not to this loop. - if ( - def?.capabilities?.roofAccessory && - !def.capabilities.roofAccessory.dirtyHandledByOwnSystem - ) { + const roofAccessory = def?.capabilities?.roofAccessory + if (roofAccessory && !roofAccessory.dirtyHandledByOwnSystem) { const segId = (node as { roofSegmentId?: string }).roofSegmentId const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined - if (seg?.parentId) { + if (roofAccessory.buildCut && seg?.parentId) { pendingRoofUpdates.add(seg.parentId as AnyNodeId) } clearDirty(id as AnyNodeId) @@ -265,7 +270,7 @@ export const RoofSystem = () => { if (roofsProcessed >= MAX_ROOFS_PER_FRAME) break const node = nodes[id] - if (!node || node.type !== 'roof') { + if (node?.type !== 'roof') { pendingRoofUpdates.delete(id) continue } @@ -319,7 +324,7 @@ function updateRoofSegmentGeometry( * Registry-driven so the viewer never names a kind. */ function subtractAccessoryCuts( - brushes: { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush }, + brushes: RoofSegmentBrushSet, segment: RoofSegmentNode, nodes: Record, ) { @@ -392,6 +397,80 @@ function subtractAccessoryCuts( brushes.wallBrush = workingWall } +function getMergedRoofSegmentBrushes( + roofNode: RoofNode, + segment: RoofSegmentNode, + nodes: Record, +): RoofSegmentBrushSet | null { + const segmentId = segment.id as AnyNodeId + const cacheKey = getMergedRoofSegmentCacheKey(roofNode, segment, nodes) + const cached = mergedRoofSegmentGeometryCache.get(segmentId) + if (cached?.key === cacheKey) { + return cloneCachedMergedRoofSegmentBrushes(cached) + } + + const brushes = withSegmentUvMatrix( + composeSegmentWorldMatrix( + roofNode.position, + roofNode.rotation ?? 0, + segment.position, + segment.rotation ?? 0, + ), + () => getRoofSegmentBrushes(segment), + ) + if (!brushes) { + disposeCachedMergedRoofSegmentGeometrySet(cached) + mergedRoofSegmentGeometryCache.delete(segmentId) + return null + } + + subtractAccessoryCuts(brushes, segment, nodes) + + _matrix.compose( + _position.set(segment.position[0], segment.position[1], segment.position[2]), + _quaternion.setFromAxisAngle(_yAxis, segment.rotation), + _scale, + ) + + const applyTransform = (brush: Brush) => { + csgGeometry(brush).applyMatrix4(_matrix) + brush.updateMatrixWorld() + } + + applyTransform(brushes.shinSlab) + applyTransform(brushes.deckSlab) + applyTransform(brushes.wallBrush) + applyTransform(brushes.innerBrush) + brushes.rakeBoards?.applyMatrix4(_matrix) + + const nextCached: CachedMergedRoofSegmentGeometrySet = { + key: cacheKey, + deckSlab: { + geometry: csgGeometry(brushes.deckSlab).clone(), + materials: csgMaterials(brushes.deckSlab), + }, + shinSlab: { + geometry: csgGeometry(brushes.shinSlab).clone(), + materials: csgMaterials(brushes.shinSlab), + }, + wallBrush: { + geometry: csgGeometry(brushes.wallBrush).clone(), + materials: csgMaterials(brushes.wallBrush), + }, + innerBrush: { + geometry: csgGeometry(brushes.innerBrush).clone(), + materials: csgMaterials(brushes.innerBrush), + }, + rakeBoards: brushes.rakeBoards?.clone() ?? null, + } + disposeCachedMergedRoofSegmentGeometrySet(cached) + mergedRoofSegmentGeometryCache.set(segmentId, nextCached) + + const cloned = cloneCachedMergedRoofSegmentBrushes(nextCached) + disposeRoofSegmentBrushSet(brushes) + return cloned +} + function updateMergedRoofGeometry( roofNode: RoofNode, group: THREE.Group, @@ -427,37 +506,15 @@ function updateMergedRoofGeometry( let totalDeckSlab: Brush | null = null let totalWall: Brush | null = null let totalInner: Brush | null = null + const rakeBoardGeometries: THREE.BufferGeometry[] = [] for (const child of children) { - const brushes = withSegmentUvMatrix( - composeSegmentWorldMatrix( - roofNode.position, - roofNode.rotation ?? 0, - child.position, - child.rotation ?? 0, - ), - () => getRoofSegmentBrushes(child), - ) + const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes) if (!brushes) continue - - subtractAccessoryCuts(brushes, child, nodes) - - _matrix.compose( - _position.set(child.position[0], child.position[1], child.position[2]), - _quaternion.setFromAxisAngle(_yAxis, child.rotation), - _scale, - ) - - const applyTransform = (brush: Brush) => { - csgGeometry(brush).applyMatrix4(_matrix) - brush.updateMatrixWorld() + if (brushes.rakeBoards) { + rakeBoardGeometries.push(brushes.rakeBoards) } - applyTransform(brushes.shinSlab) - applyTransform(brushes.deckSlab) - applyTransform(brushes.wallBrush) - applyTransform(brushes.innerBrush) - if (totalShinSlab) { const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush totalShinSlab.geometry.dispose() @@ -501,33 +558,31 @@ function updateMergedRoofGeometry( if (totalShinSlab && totalDeckSlab && totalWall && totalInner) { try { - const finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION) - prepareBrushForCSG(finalShinTrimmed) - const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION) - prepareBrushForCSG(finalDeckTrimmed) const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) prepareBrushForCSG(finalWallTrimmed) - const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION) + const shinDeck = csgEvaluator.evaluate(totalShinSlab, totalDeckSlab, ADDITION) prepareBrushForCSG(shinDeck) const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) prepareBrushForCSG(combined) const resultGeo = csgGeometry(combined) - if (geometryHasNaNPositions(resultGeo)) { + if (geometryHasInvalidAttributes(resultGeo)) { if (!warnedMergedRoofNaNIds.has(roofNode.id)) { - console.warn('[RoofSystem] Skipping merged roof geometry with NaN positions', roofNode.id) + console.warn( + '[RoofSystem] Skipping merged roof geometry with invalid attributes', + roofNode.id, + ) warnedMergedRoofNaNIds.add(roofNode.id) } resultGeo.dispose() - finalShinTrimmed.geometry.dispose() - finalDeckTrimmed.geometry.dispose() finalWallTrimmed.geometry.dispose() shinDeck.geometry.dispose() totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() totalWall.geometry.dispose() totalInner.geometry.dispose() + for (const geometry of rakeBoardGeometries) geometry.dispose() return } @@ -544,13 +599,21 @@ function updateMergedRoofGeometry( g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) } - resultGeo.computeVertexNormals() - ensureRenderableGeometryAttributes(resultGeo) + let finalGeo = resultGeo + if (rakeBoardGeometries.length > 0) { + const merged = mergeGeometriesPreservingGroups([finalGeo, ...rakeBoardGeometries]) + if (merged) { + finalGeo.dispose() + finalGeo = merged + } + } + for (const geometry of rakeBoardGeometries) geometry.dispose() + + finalGeo.computeVertexNormals() + ensureRenderableGeometryAttributes(finalGeo) mergedMesh.geometry.dispose() - mergedMesh.geometry = resultGeo + mergedMesh.geometry = finalGeo - finalShinTrimmed.geometry.dispose() - finalDeckTrimmed.geometry.dispose() finalWallTrimmed.geometry.dispose() shinDeck.geometry.dispose() } catch (e) { @@ -561,15 +624,39 @@ function updateMergedRoofGeometry( totalDeckSlab.geometry.dispose() totalWall.geometry.dispose() totalInner.geometry.dispose() + for (const geometry of rakeBoardGeometries) geometry.dispose() } } -function geometryHasNaNPositions(geometry: THREE.BufferGeometry) { +function geometryHasInvalidAttributes(geometry: THREE.BufferGeometry) { const position = geometry.getAttribute('position') - if (!position) return false + if (!(position && position.count > 0)) return true + + for (const name of ['position', 'normal', 'uv', 'uv2']) { + const attribute = geometry.getAttribute(name) + if (!attribute) continue + for (let i = 0; i < attribute.array.length; i++) { + if (!Number.isFinite(attribute.array[i])) return true + } + } + + const index = geometry.getIndex() + if (!index || index.count === 0) return true + for (let i = 0; i < index.count; i++) { + const value = index.getX(i) + if (!Number.isInteger(value) || value < 0 || value >= position.count) return true + } - for (let i = 0; i < position.array.length; i++) { - if (Number.isNaN(position.array[i])) return true + for (const group of geometry.groups) { + if ( + !Number.isInteger(group.start) || + !Number.isInteger(group.count) || + group.start < 0 || + group.count <= 0 || + group.start + group.count > index.count + ) { + return true + } } return false @@ -599,6 +686,102 @@ const dummyMats = roofCsgDummyMats export const ROOF_MATERIAL_SLOT_COUNT = 4 +type RoofSegmentBrushSet = { + deckSlab: Brush + shinSlab: Brush + wallBrush: Brush + innerBrush: Brush + rakeBoards: THREE.BufferGeometry | null +} + +type CachedMergedRoofSegmentGeometrySet = { + key: string + deckSlab: CachedMergedRoofSegmentBrush + shinSlab: CachedMergedRoofSegmentBrush + wallBrush: CachedMergedRoofSegmentBrush + innerBrush: CachedMergedRoofSegmentBrush + rakeBoards: THREE.BufferGeometry | null +} + +type CachedMergedRoofSegmentBrush = { + geometry: THREE.BufferGeometry + materials: THREE.Material[] +} + +const mergedRoofSegmentGeometryCache = new Map() + +function disposeCachedMergedRoofSegmentGeometrySet( + cached: CachedMergedRoofSegmentGeometrySet | undefined, +) { + if (!cached) return + cached.deckSlab.geometry.dispose() + cached.shinSlab.geometry.dispose() + cached.wallBrush.geometry.dispose() + cached.innerBrush.geometry.dispose() + cached.rakeBoards?.dispose() +} + +function disposeRoofSegmentBrushSet(brushes: RoofSegmentBrushSet) { + brushes.deckSlab.geometry.dispose() + brushes.shinSlab.geometry.dispose() + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + brushes.rakeBoards?.dispose() +} + +function cloneCachedBrush(cached: CachedMergedRoofSegmentBrush): Brush { + const brush = new Brush(cached.geometry.clone(), cached.materials) + prepareBrushForCSG(brush) + return brush +} + +function cloneCachedMergedRoofSegmentBrushes( + cached: CachedMergedRoofSegmentGeometrySet, +): RoofSegmentBrushSet { + return { + deckSlab: cloneCachedBrush(cached.deckSlab), + shinSlab: cloneCachedBrush(cached.shinSlab), + wallBrush: cloneCachedBrush(cached.wallBrush), + innerBrush: cloneCachedBrush(cached.innerBrush), + rakeBoards: cached.rakeBoards?.clone() ?? null, + } +} + +function getMergedRoofAccessoryCachePayload( + segment: RoofSegmentNode, + nodes: Record, +): unknown[] { + const payload: unknown[] = [] + for (const childElemId of segment.children ?? []) { + const storedChild = nodes[childElemId as AnyNodeId] + if (!storedChild) continue + const childElem = getEffectiveNode(storedChild) + const meta = + typeof childElem.metadata === 'object' && childElem.metadata !== null + ? (childElem.metadata as Record) + : undefined + if (meta?.isTransient) continue + + const childDef = nodeRegistry.get(childElem.type) + if (!childDef?.capabilities?.roofAccessory?.buildCut) continue + payload.push(childElem) + } + return payload +} + +function getMergedRoofSegmentCacheKey( + roofNode: RoofNode, + segment: RoofSegmentNode, + nodes: Record, +): string { + return JSON.stringify({ + roofPosition: roofNode.position ?? [0, 0, 0], + roofRotation: roofNode.rotation ?? 0, + segment, + accessories: getMergedRoofAccessoryCachePayload(segment, nodes), + }) +} + export function mapRoofGroupMaterialIndex( groupMaterialIndex: number | undefined, csgMaterials: THREE.Material[], @@ -635,17 +818,336 @@ function normalizeRoofMaterialIndex(materialIndex: number | undefined): number { return normalized } +function remapDutchRakeBoardMaterials(geometry: THREE.BufferGeometry) { + const position = geometry.getAttribute('position') + if (!position) return + + const index = geometry.getIndex() + const triangleCount = (index?.count ?? position.count) / 3 + if (!Number.isFinite(triangleCount) || triangleCount <= 0) return + + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + const normal = new THREE.Vector3() + const triangleMaterials = new Array(triangleCount).fill(DUTCH_RAKE_SIDE_MATERIAL_INDEX) + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + const offset = triangleIndex * 3 + const ia = index ? index.getX(offset) : offset + const ib = index ? index.getX(offset + 1) : offset + 1 + const ic = index ? index.getX(offset + 2) : offset + 2 + + a.fromBufferAttribute(position, ia) + b.fromBufferAttribute(position, ib) + c.fromBufferAttribute(position, ic) + ab.subVectors(b, a) + ac.subVectors(c, a) + normal.crossVectors(ab, ac).normalize() + + triangleMaterials[triangleIndex] = + Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON + ? DUTCH_RAKE_TOP_MATERIAL_INDEX + : DUTCH_RAKE_SIDE_MATERIAL_INDEX + } + + geometry.clearGroups() + let currentMaterial = triangleMaterials[0] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX + let groupStart = 0 + + for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) { + const materialIndex = triangleMaterials[triangleIndex] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX + if (materialIndex === currentMaterial) continue + + geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) + groupStart = triangleIndex + currentMaterial = materialIndex + } + + geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) +} + const SHINGLE_SURFACE_EPSILON = 0.02 const RAKE_FACE_NORMAL_EPSILON = 0.3 const RAKE_FACE_ALIGNMENT_EPSILON = 0.35 +const TRIM_CUT_EPSILON = 0.002 +const DUTCH_RAKE_SIDE_MATERIAL_INDEX = 1 +const DUTCH_RAKE_TOP_MATERIAL_INDEX = 3 +const DUTCH_RAKE_SLOPE_SEAT_OFFSET = 0.0002 + +function pushDoubleSidedFace(targetFaces: THREE.Vector3[][], face: THREE.Vector3[]) { + targetFaces.push(face) + targetFaces.push(face.map((point) => point.clone()).reverse()) +} + +function hasSegmentTrim(node: RoofSegmentNode): boolean { + const trim = normalizeRoofSegmentTrim(node) + return ( + trim.left > 0 || + trim.right > 0 || + trim.front > 0 || + trim.back > 0 || + trim.frontLeft > 0 || + trim.frontRight > 0 || + trim.backLeft > 0 || + trim.backRight > 0 || + trim.frontLeftX > 0 || + trim.frontLeftZ > 0 || + trim.frontRightX > 0 || + trim.frontRightZ > 0 || + trim.backLeftX > 0 || + trim.backLeftZ > 0 || + trim.backRightX > 0 || + trim.backRightZ > 0 + ) +} + +function buildTrimCutBrush( + minX: number, + maxX: number, + minZ: number, + maxZ: number, + minY: number, + maxY: number, +): Brush | null { + const width = maxX - minX + const height = maxY - minY + const depth = maxZ - minZ + if (!(width > 0 && height > 0 && depth > 0)) return null + + const geometry = new THREE.BoxGeometry(width, height, depth) + geometry.translate((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) + ensureRenderableGeometryAttributes(geometry) + computeGeometryBoundsTree(geometry) + + const cut = new Brush(geometry, dummyMats) + cut.updateMatrixWorld() + return cut +} + +type TrimPlanPoint = readonly [number, number] + +function buildDiagonalTrimCutBrush( + bounds: { + minX: number + maxX: number + minZ: number + maxZ: number + minY: number + maxY: number + }, + lineA: TrimPlanPoint, + lineB: TrimPlanPoint, + outsidePoint: TrimPlanPoint, +): Brush | null { + const dx = lineB[0] - lineA[0] + const dz = lineB[1] - lineA[1] + const lineLength = Math.hypot(dx, dz) + const height = bounds.maxY - bounds.minY + if (!(lineLength > 0 && height > 0)) return null + + const ux = dx / lineLength + const uz = dz / lineLength + let nx = -uz + let nz = ux + const midX = (lineA[0] + lineB[0]) / 2 + const midZ = (lineA[1] + lineB[1]) / 2 + const toOutsideX = outsidePoint[0] - midX + const toOutsideZ = outsidePoint[1] - midZ + let normalFlipped = false + if (nx * toOutsideX + nz * toOutsideZ < 0) { + nx *= -1 + nz *= -1 + normalFlipped = true + } + + const boundsDiagonal = Math.hypot(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ) + const cutterLength = Math.max(lineLength, boundsDiagonal) * 2 + const cutterDepth = boundsDiagonal * 2 + const centerX = midX + nx * (cutterDepth / 2 - TRIM_CUT_EPSILON) + const centerZ = midZ + nz * (cutterDepth / 2 - TRIM_CUT_EPSILON) + const yaw = Math.atan2(-uz, ux) + (normalFlipped ? Math.PI : 0) + + const geometry = new THREE.BoxGeometry(cutterLength, height, cutterDepth) + geometry.rotateY(yaw) + geometry.translate(centerX, (bounds.minY + bounds.maxY) / 2, centerZ) + ensureRenderableGeometryAttributes(geometry) + computeGeometryBoundsTree(geometry) + + const cut = new Brush(geometry, dummyMats) + cut.updateMatrixWorld() + return cut +} + +function subtractCutFromSegmentBrushes(brushes: RoofSegmentBrushSet, cut: Brush) { + for (const key of ['shinSlab', 'deckSlab', 'wallBrush', 'innerBrush'] as const) { + const next = csgEvaluator.evaluate(brushes[key], cut, SUBTRACTION) as Brush + brushes[key].geometry.dispose() + prepareBrushForCSG(next) + brushes[key] = next + } +} + +function buildSegmentTrimCutBrushes(segment: RoofSegmentNode): Brush[] { + const trim = normalizeRoofSegmentTrim(segment) + if ( + trim.left === 0 && + trim.right === 0 && + trim.front === 0 && + trim.back === 0 && + trim.frontLeft === 0 && + trim.frontRight === 0 && + trim.backLeft === 0 && + trim.backRight === 0 && + trim.frontLeftX === 0 && + trim.frontLeftZ === 0 && + trim.frontRightX === 0 && + trim.frontRightZ === 0 && + trim.backLeftX === 0 && + trim.backLeftZ === 0 && + trim.backRightX === 0 && + trim.backRightZ === 0 + ) { + return [] + } + + const { activeRh } = getSegmentSlopeFrame(segment) + const extra = + segment.wallThickness + segment.overhang + segment.deckThickness + segment.shingleThickness + 2 + const minX = -segment.width / 2 - extra + const maxX = segment.width / 2 + extra + const minZ = -segment.depth / 2 - extra + const maxZ = segment.depth / 2 + extra + const minY = -2 + const maxY = segment.wallHeight + activeRh + segment.deckThickness + segment.shingleThickness + 2 + + const cuts: Brush[] = [] + + if (trim.left > 0) { + const planeX = -segment.width / 2 + trim.left + const cut = buildTrimCutBrush(minX, planeX + TRIM_CUT_EPSILON, minZ, maxZ, minY, maxY) + if (cut) cuts.push(cut) + } + if (trim.right > 0) { + const planeX = segment.width / 2 - trim.right + const cut = buildTrimCutBrush(planeX - TRIM_CUT_EPSILON, maxX, minZ, maxZ, minY, maxY) + if (cut) cuts.push(cut) + } + if (trim.front > 0) { + const planeZ = segment.depth / 2 - trim.front + const cut = buildTrimCutBrush(minX, maxX, planeZ - TRIM_CUT_EPSILON, maxZ, minY, maxY) + if (cut) cuts.push(cut) + } + if (trim.back > 0) { + const planeZ = -segment.depth / 2 + trim.back + const cut = buildTrimCutBrush(minX, maxX, minZ, planeZ + TRIM_CUT_EPSILON, minY, maxY) + if (cut) cuts.push(cut) + } + + const leftX = -segment.width / 2 + trim.left + const rightX = segment.width / 2 - trim.right + const frontZ = segment.depth / 2 - trim.front + const backZ = -segment.depth / 2 + trim.back + + if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) { + const cut = buildDiagonalTrimCutBrush( + { minX, maxX, minZ, maxZ, minY, maxY }, + [leftX + trim.frontLeftX + TRIM_CUT_EPSILON, frontZ], + [leftX, frontZ - trim.frontLeftZ - TRIM_CUT_EPSILON], + [minX, maxZ], + ) + if (cut) cuts.push(cut) + } + if (trim.frontRightX > 0 && trim.frontRightZ > 0) { + const cut = buildDiagonalTrimCutBrush( + { minX, maxX, minZ, maxZ, minY, maxY }, + [rightX, frontZ - trim.frontRightZ - TRIM_CUT_EPSILON], + [rightX - trim.frontRightX - TRIM_CUT_EPSILON, frontZ], + [maxX, maxZ], + ) + if (cut) cuts.push(cut) + } + if (trim.backLeftX > 0 && trim.backLeftZ > 0) { + const cut = buildDiagonalTrimCutBrush( + { minX, maxX, minZ, maxZ, minY, maxY }, + [leftX, backZ + trim.backLeftZ + TRIM_CUT_EPSILON], + [leftX + trim.backLeftX + TRIM_CUT_EPSILON, backZ], + [minX, minZ], + ) + if (cut) cuts.push(cut) + } + if (trim.backRightX > 0 && trim.backRightZ > 0) { + const cut = buildDiagonalTrimCutBrush( + { minX, maxX, minZ, maxZ, minY, maxY }, + [rightX - trim.backRightX - TRIM_CUT_EPSILON, backZ], + [rightX, backZ + trim.backRightZ + TRIM_CUT_EPSILON], + [maxX, minZ], + ) + if (cut) cuts.push(cut) + } + + return cuts +} + +function subtractSegmentTrimCuts(brushes: RoofSegmentBrushSet, segment: RoofSegmentNode) { + const cuts = buildSegmentTrimCutBrushes(segment) + for (const cut of cuts) { + try { + subtractCutFromSegmentBrushes(brushes, cut) + } catch (e) { + console.error('Roof trim CSG failed:', e) + } finally { + cut.geometry.dispose() + } + } +} + +/** + * Subtract a segment's trim cuts from an arbitrary segment-local geometry, + * returning the clipped result. The input geometry is consumed (disposed) on + * each successful CSG pass — callers that need to keep the original must pass a + * clone. Returns the input untouched when the segment has no trim. Used + * internally for rake-board / end-slope attachments and exported so roof + * accessories (chimney, vents, skylight, …) can clip their own meshes by the + * same trim, in the same segment-local frame. + */ +export function clipGeometryBySegmentTrim( + geometry: THREE.BufferGeometry | null, + segment: RoofSegmentNode, +): THREE.BufferGeometry | null { + if (!geometry) return null + + const cuts = buildSegmentTrimCutBrushes(segment) + if (cuts.length === 0) return geometry + + let currentGeometry = geometry + for (const cut of cuts) { + try { + const brush = new Brush(currentGeometry, dummyMats) + prepareBrushForCSG(brush) + const next = csgEvaluator.evaluate(brush, cut, SUBTRACTION) as Brush + const trimmed = csgGeometry(next).clone() + currentGeometry.dispose() + next.geometry.dispose() + ensureRenderableGeometryAttributes(trimmed) + currentGeometry = trimmed + } catch (e) { + console.error('Roof trim CSG failed for attachment geometry:', e) + } finally { + cut.geometry.dispose() + } + } + + return currentGeometry +} /** * Generate complete hollow-shell geometry for a roof segment. * Ports the prototype's CSG approach using three-bvh-csg. */ -export function getRoofSegmentBrushes( - node: RoofSegmentNode, -): { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush } | null { +export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSet | null { const { roofType, width, @@ -658,14 +1160,20 @@ export function getRoofSegmentBrushes( } = node const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node) - const shapeRatios: ShapeWidthRatios = { + const shapeRatios = getRoofShapeRatios({ gambrelLowerWidthRatio: node.gambrelLowerWidthRatio, mansardSteepWidthRatio: node.mansardSteepWidthRatio, dutchHipWidthRatio: node.dutchHipWidthRatio, - } + dutchHipHeightRatio: node.dutchHipHeightRatio, + dutchWaistLengthRatio: node.dutchWaistLengthRatio, + dutchGabletRake: node.dutchGabletRake, + }) const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness - const baseI = Math.min(width, depth) * 0.25 + // Gablet inset must track dutchHipWidthRatio so the 3D waist matches both + // the 2D floorplan and the slope frame (which derives activeRh from the + // same ratio). A hardcoded 0.25 desyncs the gablet from the parameter. + const baseI = Math.min(width, depth) * node.dutchHipWidthRatio const getVol = ( wExt: number, @@ -693,19 +1201,20 @@ export function getRoofSegmentBrushes( structuralI += deckThickness } - const faces = getModuleFaces( - roofType, - wV, - dV, - whV, - rhV, - safeBaseY, - { dutchI: structuralI }, - width, - depth, + const faces = getRoofModuleFaces({ + type: roofType, + w: wV, + d: dV, + wh: whV, + rh: rhV, + baseY: safeBaseY, + insets: { dutchI: structuralI }, + baseW: width, + baseD: depth, tanTheta, shapeRatios, - ) + dutchTopRakeThickness: node.dutchTopRakeThickness, + }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) return createGeometryFromFaces(faces, matIndex) } @@ -762,73 +1271,86 @@ export function getRoofSegmentBrushes( const topBaseY = shinBotWh - dropTop const botBaseY = shinBotWh - dropBot - const getInsets = (wh: number, bY: number, isVoid: boolean, brushW: number, brushD: number) => { - let inset = (wh - bY) * tanTheta - const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005 - if (inset > maxSafeInset) { - inset = maxSafeInset - } - - let iF = 0, - iB = 0, - iL = 0, - iR = 0 - if (['hip', 'mansard', 'dutch'].includes(roofType)) { - iF = inset - iB = inset - iL = inset - iR = inset - } else if (['gable', 'gambrel'].includes(roofType)) { - iF = inset - iB = inset - } else if (roofType === 'shed') { - iF = inset - } - - let structuralI = baseI - if (isVoid) { - structuralI += shingleThickness - } - return { iF, iB, iL, iR, dutchI: structuralI } - } - - const insetsBot = getInsets(shinBotWh, botBaseY, true, shinBotW, shinBotD) - const insetsTop = getInsets(shinTopWh, topBaseY, false, shinTopW, shinTopD) - - const botFaces = getModuleFaces( + const insetsBot = getRoofShapeInsets({ roofType, - shinBotW, - shinBotD, - shinBotWh, - shinBotRh, - botBaseY, - insetsBot, width, depth, + wh: shinBotWh, + baseY: botBaseY, + isVoid: true, + brushW: shinBotW, + brushD: shinBotD, tanTheta, - shapeRatios, - ) - const topFaces = getModuleFaces( + shingleThickness, + dutchHipWidthRatio: node.dutchHipWidthRatio, + }) + const insetsTop = getRoofShapeInsets({ roofType, - shinTopW, - shinTopD, - shinTopWh, - shinTopRh, - topBaseY, - insetsTop, width, depth, + wh: shinTopWh, + baseY: topBaseY, + isVoid: false, + brushW: shinTopW, + brushD: shinTopD, + tanTheta, + shingleThickness, + dutchHipWidthRatio: node.dutchHipWidthRatio, + }) + + const botFaces = getRoofModuleFaces({ + type: roofType, + w: shinBotW, + d: shinBotD, + wh: shinBotWh, + rh: shinBotRh, + baseY: botBaseY, + insets: insetsBot, + baseW: width, + baseD: depth, tanTheta, shapeRatios, - ) + dutchTopRakeThickness: node.dutchTopRakeThickness, + }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) + const topFaces = getRoofModuleFaces({ + type: roofType, + w: shinTopW, + d: shinTopD, + wh: shinTopWh, + rh: shinTopRh, + baseY: topBaseY, + insets: insetsTop, + baseW: width, + baseD: depth, + tanTheta, + shapeRatios, + dutchTopRakeThickness: node.dutchTopRakeThickness, + }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) + + let rakeBoards: THREE.BufferGeometry | null = null + if (roofType === 'dutch' && insetsTop.dutchI !== undefined) { + rakeBoards = buildDutchRakeBoards( + shinTopW, + shinTopD, + shinTopWh, + shinTopRh, + insetsTop.dutchI, + shapeRatios, + node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, + node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness, + ) + } - const shinBotGeo = createGeometryFromFaces(botFaces, 1) + const shinBotGeo = createGeometryFromFaces(botFaces, (normal) => + normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, + ) const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, ) if (transZ !== 0) { shinTopGeo.translate(0, 0, transZ) + rakeBoards?.translate(0, 0, transZ) } const toBrush = (geo: THREE.BufferGeometry): Brush | null => { @@ -839,6 +1361,7 @@ export function getRoofSegmentBrushes( geo.groups = geo.groups.filter((g) => g.count > 0) if (geo.groups.length === 0) return null ensureRenderableGeometryAttributes(geo) + if (geometryHasInvalidAttributes(geo)) return null computeGeometryBoundsTree(geo) const brush = new Brush(geo, dummyMats) brush.updateMatrixWorld() @@ -893,7 +1416,26 @@ export function getRoofSegmentBrushes( shinTopBrush.geometry.dispose() shinBotBrush.geometry.dispose() - return { deckSlab, shinSlab, wallBrush, innerBrush } + const brushes = { + deckSlab, + shinSlab, + wallBrush, + innerBrush, + rakeBoards, + } + if (hasSegmentTrim(node)) { + subtractSegmentTrimCuts(brushes, node) + brushes.rakeBoards = clipGeometryBySegmentTrim(brushes.rakeBoards, node) + // The clip is a CSG subtraction: rake faces can come back as + // `slot + 4n` because the cutter contributes its own material array. + // Preserve top roof-material faces (slot 3) and force only cutter / + // side faces back to the rake side material. + if (brushes.rakeBoards) { + remapDutchRakeBoardMaterials(brushes.rakeBoards) + } + } + + return brushes } catch (e) { console.error('CSG prep failed:', e) } @@ -905,6 +1447,7 @@ export function getRoofSegmentBrushes( if (shinBotBrush) shinBotBrush.geometry.dispose() if (wallBrush) wallBrush.geometry.dispose() if (innerBrush) innerBrush.geometry.dispose() + rakeBoards?.dispose() return null } @@ -938,7 +1481,7 @@ export function generateRoofSegmentGeometry( subtractAccessoryCuts(brushes, node, nodes) } - const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes + const { deckSlab, shinSlab, wallBrush, innerBrush, rakeBoards } = brushes let resultGeo = new THREE.BufferGeometry() try { @@ -950,6 +1493,10 @@ export function generateRoofSegmentGeometry( prepareBrushForCSG(combined) resultGeo = csgGeometry(combined) + if (geometryHasInvalidAttributes(resultGeo)) { + resultGeo.dispose() + resultGeo = csgGeometry(wallBrush).clone() + } const resultMaterials = csgMaterials(combined) @@ -982,6 +1529,15 @@ export function generateRoofSegmentGeometry( wallBrush.geometry.dispose() innerBrush.geometry.dispose() + if (rakeBoards) { + const merged = mergeGeometriesPreservingGroups([resultGeo, rakeBoards]) + rakeBoards.dispose() + if (merged) { + resultGeo.dispose() + resultGeo = merged + } + } + resultGeo.computeVertexNormals() ensureRenderableGeometryAttributes(resultGeo) return resultGeo @@ -999,6 +1555,174 @@ type Insets = { dutchI?: number } +type RawGeometryGroup = { + start: number + count: number + materialIndex: number +} + +function createGeometryFromRawAttributes( + positions: number[], + normals: number[], + uvs: number[], + groups: RawGeometryGroup[], +): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + geometry.setIndex(Array.from({ length: positions.length / 3 }, (_, i) => i)) + for (const group of groups) { + geometry.addGroup(group.start, group.count, group.materialIndex) + } + ensureRenderableGeometryAttributes(geometry) + return geometry +} + +function mergeGeometriesPreservingGroups( + geometries: THREE.BufferGeometry[], +): THREE.BufferGeometry | null { + if (geometries.length === 0) return null + + const merged = mergeGeometries(geometries, false) + if (!merged) return null + + merged.clearGroups() + + let indexStart = 0 + for (const geometry of geometries) { + if (geometry.groups.length > 0) { + for (const group of geometry.groups) { + merged.addGroup(indexStart + group.start, group.count, group.materialIndex ?? 0) + } + } else { + const count = geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0 + if (count > 0) { + merged.addGroup(indexStart, count, 0) + } + } + + indexStart += geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0 + } + + return merged +} + +function collectGeometryPlanes(geometry: THREE.BufferGeometry): THREE.Plane[] { + const source = geometry.index ? geometry.toNonIndexed() : geometry + const position = source.getAttribute('position') as THREE.BufferAttribute | undefined + const planes: THREE.Plane[] = [] + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + if (!position) return planes + for (let i = 0; i + 2 < position.count; i += 3) { + a.fromBufferAttribute(position, i) + b.fromBufferAttribute(position, i + 1) + c.fromBufferAttribute(position, i + 2) + const plane = new THREE.Plane().setFromCoplanarPoints(a, b, c) + if (Number.isFinite(plane.normal.x) && plane.normal.lengthSq() > 1e-10) { + planes.push(plane.normalize()) + } + } + if (source !== geometry) source.dispose() + return planes +} + +function splitShellByFacePlanes( + geometry: THREE.BufferGeometry, + planes: THREE.Plane[], + materialIndex: number, +): THREE.BufferGeometry { + if (planes.length === 0) return geometry + + const source = geometry.index ? geometry.toNonIndexed() : geometry.clone() + const position = source.getAttribute('position') as THREE.BufferAttribute | undefined + const normal = source.getAttribute('normal') as THREE.BufferAttribute | undefined + const uv = source.getAttribute('uv') as THREE.BufferAttribute | undefined + if (!position || !normal || !uv) { + source.dispose() + return geometry + } + + const groups = + source.groups.length > 0 ? source.groups : [{ start: 0, count: position.count, materialIndex }] + const keptPositions: number[] = [] + const keptNormals: number[] = [] + const keptUvs: number[] = [] + const keptGroups: RawGeometryGroup[] = [] + let keptVertexCount = 0 + const point = new THREE.Vector3() + const triNormal = new THREE.Vector3() + + const pushTriangle = ( + targetPositions: number[], + targetNormals: number[], + targetUvs: number[], + targetGroups: RawGeometryGroup[], + startVertex: number, + groupMaterialIndex: number, + vertexOffset: number, + ) => { + if ( + targetGroups.length === 0 || + targetGroups[targetGroups.length - 1]!.materialIndex !== groupMaterialIndex + ) { + targetGroups.push({ start: startVertex, count: 0, materialIndex: groupMaterialIndex }) + } + const targetGroup = targetGroups[targetGroups.length - 1]! + for (let k = 0; k < 3; k += 1) { + const vi = vertexOffset + k + targetPositions.push(position.getX(vi), position.getY(vi), position.getZ(vi)) + targetNormals.push(normal.getX(vi), normal.getY(vi), normal.getZ(vi)) + targetUvs.push(uv.getX(vi), uv.getY(vi)) + } + targetGroup.count += 3 + } + + const isPlaneMatch = (vertexOffset: number) => { + if ( + materialIndex >= 0 && + triNormal.fromBufferAttribute(normal, vertexOffset).y <= SHINGLE_SURFACE_EPSILON + ) { + return false + } + triNormal.fromBufferAttribute(normal, vertexOffset).normalize() + return planes.some((plane) => { + if (Math.abs(triNormal.dot(plane.normal)) < 0.999) return false + for (let k = 0; k < 3; k += 1) { + point.fromBufferAttribute(position, vertexOffset + k) + if (Math.abs(plane.distanceToPoint(point)) > 1e-3) return false + } + return true + }) + } + + for (const group of groups) { + const groupStart = Math.max(0, group.start) + const groupEnd = Math.min(position.count, group.start + group.count) + for (let i = groupStart; i + 2 < groupEnd; i += 3) { + if (group.materialIndex === materialIndex && isPlaneMatch(i)) { + continue + } + pushTriangle( + keptPositions, + keptNormals, + keptUvs, + keptGroups, + keptVertexCount, + group.materialIndex ?? 0, + i, + ) + keptVertexCount += 3 + } + } + + source.dispose() + geometry.dispose() + return createGeometryFromRawAttributes(keptPositions, keptNormals, keptUvs, keptGroups) +} + export function remapRoofShellFaces(geometry: THREE.BufferGeometry, node: RoofSegmentNode) { const position = geometry.getAttribute('position') const index = geometry.getIndex() @@ -1044,12 +1768,14 @@ export function remapRoofShellFaces(geometry: THREE.BufferGeometry, node: RoofSe .add(c) .multiplyScalar(1 / 3) - if (normal.y > SHINGLE_SURFACE_EPSILON) { + if (node.roofType === 'dutch' && Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON) { + materialIndex = 3 + } else if (normal.y > SHINGLE_SURFACE_EPSILON) { materialIndex = 3 } else if (isRakeFace(node, geometry, centroid, normal)) { - materialIndex = 0 - } else { materialIndex = 1 + } else { + materialIndex = 0 } } @@ -1106,7 +1832,6 @@ function isRakeFace( function getRakeAxis(node: RoofSegmentNode): 'x' | 'z' | null { if (node.roofType === 'gable' || node.roofType === 'gambrel') return 'x' - if (node.roofType === 'dutch') return node.width >= node.depth ? 'x' : 'z' return null } @@ -1114,6 +1839,9 @@ type ShapeWidthRatios = { gambrelLowerWidthRatio: number mansardSteepWidthRatio: number dutchHipWidthRatio: number + dutchHipHeightRatio: number + dutchWaistLengthRatio: number + dutchGabletRake: number } /** @@ -1136,6 +1864,7 @@ function getModuleFaces( baseD: number, tanTheta: number, shapeRatios: ShapeWidthRatios, + dutchTopRakeThickness?: number, ): THREE.Vector3[][] { const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z) const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets @@ -1206,82 +1935,270 @@ function getModuleFaces( const m2 = v(w / 2 - i, mh, d / 2 - i) const m3 = v(w / 2 - i, mh, -d / 2 + i) const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2) - const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2) - const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2) - const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2) - if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) { - if (w >= d) { - const r1 = v(-w / 2 + d / 2, h, 0) - const r2 = v(w / 2 - d / 2, h, 0) - faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) - } else { - const r1 = v(0, h, d / 2 - w / 2) - const r2 = v(0, h, -d / 2 + w / 2) - faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) - } + const topW = w - i * 2 + const topD = 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 { - faces.push( - [t1, t2, t3, t4], - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m1, m2, t2, t1], - [m2, m3, t3, t2], - [m3, m4, t4, t3], - [m4, m1, t1, t4], - ) + 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 (type === 'dutch') { - const i = - insets.dutchI !== undefined - ? insets.dutchI - : Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio - const mh = wh + i * (tanTheta || 0) - - if (w >= d) { - const m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const r1 = v(-w / 2 + i, h, 0) - const r2 = v(w / 2 - i, h, 0) - - faces.push( - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m4, m1, r1], - [m2, m3, r2], - [m1, m2, r2, r1], - [m3, m4, r1, r2], - ) + const dutch = getDutchRoofShapeMetrics({ + w, + d, + wh, + rh, + dutchI: insets.dutchI, + baseW, + baseD, + shapeRatios, + }) + if (!dutch) return faces + + if (dutch.axis === 'width') { + 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 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 = getDutchEndSlopeFaces({ + w, + d, + wh, + rh, + insets, + baseW, + baseD, + shapeRatios, + dutchTopRakeThickness, + }).map((face) => face.map((point) => v(point.x, point.y, point.z))) + faces.push([e1, e2, o2, m2, m1, o1], [e3, e4, o4, m4, m3, o3]) + if (endSlopes.length === 2) { + faces.push(...endSlopes) + } else { + 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 m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const r1 = v(0, h, d / 2 - i) - const r2 = v(0, h, -d / 2 + i) - - faces.push( - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m1, m2, r1], - [m3, m4, r2], - [m2, m3, r2, r1], - [m4, m1, r1, r2], - ) + 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 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 = getDutchEndSlopeFaces({ + w, + d, + wh, + rh, + insets, + baseW, + baseD, + shapeRatios, + dutchTopRakeThickness, + }).map((face) => face.map((point) => v(point.x, point.y, point.z))) + faces.push([e2, e3, o3, m3, m2, o2], [e4, e1, o1, m1, m4, o4]) + if (endSlopes.length === 2) { + faces.push(...endSlopes) + } else { + 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 } +function addDutchRakeBoard( + apex: THREE.Vector3, + base: THREE.Vector3, + outward: THREE.Vector3, + reach: number, + thickness: number, + topFaces: THREE.Vector3[][], + sideFaces: THREE.Vector3[][], +) { + if (!(reach > 0.001) || !(thickness > 0.0001)) return + + const apexOuter = apex.clone().addScaledVector(outward, reach) + const baseOuter = base.clone().addScaledVector(outward, reach) + const topPoly = [apex.clone(), base.clone(), baseOuter, apexOuter] + + const normal = new THREE.Vector3() + .crossVectors( + new THREE.Vector3().subVectors(topPoly[1]!, topPoly[0]!), + new THREE.Vector3().subVectors(topPoly[2]!, topPoly[0]!), + ) + .normalize() + if (normal.y < 0) { + topPoly.reverse() + normal.multiplyScalar(-1) + } + + const top = topPoly.map((point) => + point.clone().addScaledVector(normal, DUTCH_RAKE_SLOPE_SEAT_OFFSET), + ) + const bottom = top.map((point) => new THREE.Vector3(point.x, point.y - thickness, point.z)) + pushDoubleSidedFace(topFaces, top) + pushDoubleSidedFace(sideFaces, bottom.slice().reverse()) + for (let i = 0; i < top.length; i += 1) { + const next = (i + 1) % top.length + pushDoubleSidedFace(sideFaces, [ + top[i]!.clone(), + top[next]!.clone(), + bottom[next]!.clone(), + bottom[i]!.clone(), + ]) + } +} + +function buildDutchRakeBoards( + W: number, + D: number, + wh: number, + rh: number, + i: number, + shapeRatios: ShapeWidthRatios, + rake: number, + thickness: number, +): THREE.BufferGeometry | null { + if (!(rake > 0.001) || !(thickness > 0.0001) || !(i > 0.001) || !(rh > 0.001)) { + return null + } + + const dutch = getDutchRoofShapeMetrics({ + w: W, + d: D, + wh, + rh, + dutchI: i, + baseW: W, + baseD: D, + shapeRatios: { + ...shapeRatios, + dutchGabletRake: rake, + }, + }) + if (!dutch || !(dutch.rakeReach > 0.001)) return null + + const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z) + const topFaces: THREE.Vector3[][] = [] + const sideFaces: THREE.Vector3[][] = [] + + if (dutch.axis === 'width') { + addDutchRakeBoard( + v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), + v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), + v(-1, 0, 0), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), + v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), + v(-1, 0, 0), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(dutch.innerWaistHalfX, dutch.peakHeight, 0), + v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), + v(1, 0, 0), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(dutch.innerWaistHalfX, dutch.peakHeight, 0), + v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), + v(1, 0, 0), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + } else { + addDutchRakeBoard( + v(0, dutch.peakHeight, dutch.innerWaistHalfZ), + v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), + v(0, 0, 1), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(0, dutch.peakHeight, dutch.innerWaistHalfZ), + v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), + v(0, 0, 1), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), + v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), + v(0, 0, -1), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), + v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), + v(0, 0, -1), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + } + + const geometries: THREE.BufferGeometry[] = [] + if (topFaces.length > 0) { + geometries.push(createGeometryFromFaces(topFaces, DUTCH_RAKE_TOP_MATERIAL_INDEX)) + } + if (sideFaces.length > 0) { + geometries.push(createGeometryFromFaces(sideFaces, DUTCH_RAKE_SIDE_MATERIAL_INDEX)) + } + + if (geometries.length === 0) return null + if (geometries.length === 1) return geometries[0]! + + const merged = mergeGeometriesPreservingGroups(geometries) + for (const geometry of geometries) geometry.dispose() + return merged +} + /** * Converts an array of face polygons into a BufferGeometry. * Each face is triangulated via fan triangulation. @@ -1289,6 +2206,9 @@ function getModuleFaces( function createGeometryFromFaces( faces: THREE.Vector3[][], matRule: number | ((normal: THREE.Vector3) => number) | null = null, + options?: { + treatBidirectionalSlopeFacesAsSlope?: boolean + }, ): THREE.BufferGeometry { const positions: number[] = [] const normals: number[] = [] @@ -1306,15 +2226,21 @@ function createGeometryFromFaces( const vA = new THREE.Vector3().subVectors(p1, p0) const vB = new THREE.Vector3().subVectors(p2, p0) const normal = new THREE.Vector3().crossVectors(vA, vB).normalize() + if (normal.lengthSq() < 1e-12) continue let slopeAlignedDown: THREE.Vector3 | null = null let slopeAlignedAcross: THREE.Vector3 | null = null let slopeAlignedVOrigin = 0 - if (normal.y > SHINGLE_SURFACE_EPSILON) { - _uvDownSlope.copy(_uvWorldDown).projectOnPlane(normal) + const slopeUvNormal = + options?.treatBidirectionalSlopeFacesAsSlope && normal.y < 0 + ? normal.clone().multiplyScalar(-1) + : normal + + if (Math.abs(slopeUvNormal.y) > SHINGLE_SURFACE_EPSILON) { + _uvDownSlope.copy(_uvWorldDown).projectOnPlane(slopeUvNormal) if (_uvDownSlope.lengthSq() > 1e-8) { _uvDownSlope.normalize() - _uvAcrossSlope.crossVectors(_uvDownSlope, normal).normalize() + _uvAcrossSlope.crossVectors(_uvDownSlope, slopeUvNormal).normalize() let highestPoint = face[0]! for (const candidate of face) { @@ -1504,6 +2430,9 @@ export function getRoofOuterSurfaceFrameAtPoint( const topBaseY = 0 const baseI = Math.min(width, depth) * 0.25 + // Dutch gablet waist tracks dutchHipWidthRatio (see getRoofSegmentBrushes); + // the generic baseI above still drives the bottom-rect insets for other types. + const dutchBaseI = Math.min(width, depth) * segment.dutchHipWidthRatio const getInsets = ( _wh: number, _baseY: number, @@ -1534,7 +2463,7 @@ export function getRoofOuterSurfaceFrameAtPoint( iF = inset } - let structuralI = baseI + let structuralI = dutchBaseI if (isVoid) { structuralI += shingleThickness } @@ -1547,6 +2476,10 @@ export function getRoofOuterSurfaceFrameAtPoint( gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio, mansardSteepWidthRatio: segment.mansardSteepWidthRatio, dutchHipWidthRatio: segment.dutchHipWidthRatio, + dutchHipHeightRatio: segment.dutchHipHeightRatio, + dutchWaistLengthRatio: + segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + dutchGabletRake: segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, } const topFaces = getModuleFaces( roofType, @@ -1560,6 +2493,7 @@ export function getRoofOuterSurfaceFrameAtPoint( depth, tanTheta, shapeRatios, + segment.dutchTopRakeThickness, ) const topGeo = createGeometryFromFaces(topFaces, (normal) => diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 78addcf32..61b280637 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -48,7 +48,7 @@ export const SlabSystem = () => { // Process dirty slabs dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'slab') return + if (node?.type !== 'slab') return const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (mesh) { diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index 19bd62a22..7581b2c05 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -88,7 +88,7 @@ export const StairSystem = () => { // --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) --- for (const stairId of parentsNeedingSegmentSync) { const baseStairNode = nodes[stairId] - if (!baseStairNode || baseStairNode.type !== 'stair') continue + if (baseStairNode?.type !== 'stair') continue // Merge any in-flight drag override (e.g. parent-stair rotate handle) // so slab-elevation spatial queries match where the segments are // actually being rendered. Without this, dragging the rotate gizmo @@ -110,7 +110,7 @@ export const StairSystem = () => { if (stairsProcessed >= MAX_STAIRS_PER_FRAME) break const node = nodes[id] - if (!node || node.type !== 'stair') { + if (node?.type !== 'stair') { pendingStairUpdates.delete(id) continue } @@ -1075,7 +1075,7 @@ function computeAbsoluteHeight(node: StairSegmentNode): number { if (!node.parentId) return 0 const parent = nodes[node.parentId as AnyNodeId] - if (!parent || parent.type !== 'stair') return 0 + if (parent?.type !== 'stair') return 0 const stair = parent as StairNode const segments = (stair.children ?? []) diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 4fa7779eb..52d23d47e 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -103,7 +103,7 @@ export const WallCutout = () => { const wallMesh = sceneRegistry.nodes.get(wallId) if (!wallMesh) return const wallNode = useScene.getState().nodes[wallId as WallNode['id']] - if (!wallNode || wallNode.type !== 'wall') return + if (wallNode?.type !== 'wall') return const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const isDeleteHighlighted = deleteHoveredWallId === wallId @@ -155,7 +155,7 @@ export const WallCutout = () => { const wallMesh = sceneRegistry.nodes.get(wallId) as Mesh | undefined if (!wallMesh) return const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined - if (!wallNode || wallNode.type !== 'wall') return + if (wallNode?.type !== 'wall') return const mats = getMaterialsForWall( wallNode, useViewer.getState().shading, diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 8ebe59be6..824dc21b5 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -366,7 +366,7 @@ export const WallSystem = () => { if (hasDirty) { dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'wall') return + if (node?.type !== 'wall') return const levelId = node.parentId if (!levelId) return @@ -515,7 +515,7 @@ function getLevelWalls(levelId: string): WallNode[] { const { nodes } = useScene.getState() const level = nodes[levelId as AnyNodeId] - if (!level || level.type !== 'level') return [] + if (level?.type !== 'level') return [] const walls: WallNode[] = [] for (const childId of level.children) { @@ -536,7 +536,7 @@ function getLevelWalls(levelId: string): WallNode[] { function updateWallGeometry(wallId: string, miterData: WallMiterData) { const nodes = useScene.getState().nodes const sceneNode = nodes[wallId as WallNode['id']] - if (!sceneNode || sceneNode.type !== 'wall') return + if (sceneNode?.type !== 'wall') return const node = getEffectiveWall(sceneNode as WallNode) const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 618971b96..32f8c6760 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -116,7 +116,7 @@ export const WindowSystem = () => { dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'window') return + if (node?.type !== 'window') return dirtyWindowIds.push(id as AnyNodeId) }) @@ -138,7 +138,7 @@ export const WindowSystem = () => { } const node = nodes[id] - if (!node || node.type !== 'window') continue + if (node?.type !== 'window') continue const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (!mesh) continue // Keep dirty until mesh mounts