diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index b42f82b065..5ba049ddbe 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -92,6 +92,7 @@ function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { const extension = getFloorplanNodeExtension(definition) if ( baseKinds.has(kind) || + definition.presentation?.paletteGroup === 'roof-features' || !extension?.tool || !isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) || !presentation || @@ -173,13 +174,12 @@ type RoofFeature = { kind: string; label: string; iconSrc: string } const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp' /** - * Roof accessories surfaced under the Roof tile (a "Features" group). Unlike - * the community editor these aren't DB presets — each is a registry kind with - * `capabilities.roofAccessory`, enumerated from the registry at render time - * (it is populated by the app bootstrap — a module-scope const would race it) - * and activated like any structure tool (the kind's tool attaches it to the - * roof segment under the cursor). Label + icon come from the registry's - * `presentation`; non-url icons fall back to the roof icon. + * Roof accessories and extensions surfaced under the Roof tile. Unlike the + * community editor these aren't DB presets — each is a registry kind, either + * carrying `capabilities.roofAccessory` or explicitly classified as a roof + * extension. They are enumerated at render time because the registry is + * populated during app bootstrap. Label + icon come from `presentation`; + * non-url icons fall back to the roof icon. */ function activateRoofFeatureTool(kind: string): void { const ed = useEditor.getState() @@ -243,9 +243,15 @@ export function BuildTab() { // Read at render time (not module scope): the registry is populated by the // app bootstrap, so enumerating earlier would race it and see no kinds. const roofFeatures = useMemo(() => { + if (!registryReady) return [] const features: RoofFeature[] = [] for (const [kind, def] of nodeRegistry.entries()) { - if (def.capabilities.roofAccessory === undefined) continue + if ( + def.capabilities.roofAccessory === undefined && + def.presentation?.paletteGroup !== 'roof-features' + ) { + continue + } // Door / window declare `roofAccessory` for the wall-face cut but // already have their own Build tiles — listing them here too // would duplicate the entry under Roof → Features. @@ -258,7 +264,7 @@ export function BuildTab() { }) } return features - }, []) + }, [registryReady]) // Tile highlight derives from the single source of truth (the active tool / // mode), never a separate local selection — so keyboard shortcuts and panel @@ -362,7 +368,9 @@ export function BuildTab() { (activeTool === 'roof' || isRoofFeatureActive) && roofFeatures.length > 0 ? (
-
Features
+
+ Features & extensions +
export type CabinetEvent = NodeEvent export type CabinetModuleEvent = NodeEvent export type LevelEvent = NodeEvent +export type LeanToExtensionEvent = NodeEvent export type ZoneEvent = NodeEvent export type ShelfEvent = NodeEvent export type SlabEvent = NodeEvent @@ -305,6 +307,7 @@ type EditorEvents = GridEvents & NodeEvents<'building', BuildingEvent> & NodeEvents<'elevator', ElevatorEvent> & NodeEvents<'level', LevelEvent> & + NodeEvents<'lean-to-extension', LeanToExtensionEvent> & NodeEvents<'zone', ZoneEvent> & NodeEvents<'slab', SlabEvent> & NodeEvents<'shelf', ShelfEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e887a95491..cc7825ff01 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,7 @@ export type { GuideEvent, GutterEvent, ItemEvent, + LeanToExtensionEvent, LevelEvent, MeasurementEvent, NodeEvent, diff --git a/packages/core/src/registry/scene-api.ts b/packages/core/src/registry/scene-api.ts index 0c4ed37f15..6983acc1c1 100644 --- a/packages/core/src/registry/scene-api.ts +++ b/packages/core/src/registry/scene-api.ts @@ -1,5 +1,9 @@ import type { AnyNode, AnyNodeId } from '../schema/types' -import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control' +import { + activeSceneCommitNodeIds, + pauseSceneHistory, + resumeSceneHistory, +} from '../store/history-control' import { type CloneNodesIntoOptions, collectSubtree, @@ -20,10 +24,21 @@ export type SceneStoreLike = { dirtyNodes: Set createNode: (node: AnyNode, parentId?: AnyNodeId) => void createNodes?: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void + applyNodeChanges?: (changes: { + create?: { node: AnyNode; parentId?: AnyNodeId }[] + update?: { id: AnyNodeId; data: Partial }[] + delete?: AnyNodeId[] + }) => void updateNode: (id: AnyNodeId, data: Partial) => void deleteNode: (id: AnyNodeId) => void markDirty: (id: AnyNodeId) => void } + subscribe?: ( + listener: ( + state: { nodes: Record }, + previous: { nodes: Record }, + ) => void, + ) => () => void temporal: { getState: () => { pause: () => void; resume: () => void } } @@ -71,6 +86,46 @@ export function createSceneApi(store: SceneStoreLike): SceneApi { return node.id }, + createMany(ops) { + for (const op of ops) captureIfNeeded(op.node.id) + const batch = store.getState().createNodes + if (batch) batch(ops) + else for (const op of ops) this.upsert(op.node, op.parentId) + }, + + applyChanges(changes) { + for (const op of changes.create ?? []) captureIfNeeded(op.node.id) + for (const op of changes.update ?? []) captureIfNeeded(op.id) + for (const id of changes.delete ?? []) captureIfNeeded(id) + const batch = store.getState().applyNodeChanges + if (batch) { + batch(changes) + return + } + for (const op of changes.create ?? []) this.upsert(op.node, op.parentId) + for (const op of changes.update ?? []) this.update(op.id, op.data) + for (const id of changes.delete ?? []) this.delete(id) + }, + + subscribeNodes(listener) { + return ( + store.subscribe?.((state, previous) => { + if (state.nodes === previous.nodes) return + const scopedIds = activeSceneCommitNodeIds() + const changedIds = new Set(scopedIds) + if (!scopedIds) { + for (const id of Object.keys(state.nodes) as AnyNodeId[]) { + if (state.nodes[id] !== previous.nodes[id]) changedIds.add(id) + } + for (const id of Object.keys(previous.nodes) as AnyNodeId[]) { + if (!(id in state.nodes)) changedIds.add(id) + } + } + listener(state.nodes, previous.nodes, changedIds) + }) ?? (() => {}) + ) + }, + delete(id) { captureIfNeeded(id) store.getState().deleteNode(id) diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 9549ecaa2d..97f0927e50 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -793,6 +793,8 @@ export type FloorplanAffordance = { initialPlanPoint: FloorplanAffordancePoint /** Active editor grid step in meters. */ gridSnapStep: number + /** Injected mutation/read seam for kind-owned affordances. */ + sceneApi?: SceneApi }): FloorplanAffordanceSession } @@ -875,6 +877,7 @@ export type FloorplanMoveTargetSession = { export type FloorplanMoveTarget = (args: { node: N nodes: Record + sceneApi?: SceneApi }) => FloorplanMoveTargetSession // ─── Plugin manifest ───────────────────────────────────────────────── @@ -1436,6 +1439,8 @@ export type Presentation = { icon: IconRef /** Tool palette section. Defaults to `category` when omitted. */ paletteSection?: 'site' | 'structure' | 'furnish' + /** Optional presentation-only subgroup used by palette surfaces. */ + paletteGroup?: string /** Sort key within a palette section; lower numbers come first. */ paletteOrder?: number /** Set true for kinds that exist but should NOT appear in the palette @@ -2216,7 +2221,7 @@ export type ParametricDescriptor = { * Direct store/MCP writes bypass it — keep real invariants in * `invariants`. */ - derive?: (next: N, patch: Partial) => Partial + derive?: (next: N, patch: Partial, previous?: N) => Partial /** * Cross-node companion to `derive`: after an inspector edit lands on * this node, return patches for OTHER nodes that must follow to keep @@ -2293,6 +2298,7 @@ export type ParamGroup = { export type ParamField = | { key: keyof N + label?: string kind: 'number' unit?: string min?: number @@ -2301,9 +2307,10 @@ export type ParamField = visibleIf?: (n: N) => boolean customEditor?: ComponentType } - | { key: keyof N; kind: 'boolean'; visibleIf?: (n: N) => boolean } + | { key: keyof N; label?: string; kind: 'boolean'; visibleIf?: (n: N) => boolean } | { key: keyof N + label?: string kind: 'enum' options: readonly string[] /** Defaults to 'select' (dropdown). 'segmented' renders the inline @@ -2311,10 +2318,10 @@ export type ParamField = display?: 'select' | 'segmented' visibleIf?: (n: N) => boolean } - | { key: keyof N; kind: 'vec3'; visibleIf?: (n: N) => boolean } - | { key: keyof N; kind: 'color'; visibleIf?: (n: N) => boolean } - | { key: keyof N; kind: 'material'; visibleIf?: (n: N) => boolean } - | { key: keyof N; kind: 'ref'; refKind: string; visibleIf?: (n: N) => boolean } + | { key: keyof N; label?: string; kind: 'vec3'; visibleIf?: (n: N) => boolean } + | { key: keyof N; label?: string; kind: 'color'; visibleIf?: (n: N) => boolean } + | { key: keyof N; label?: string; kind: 'material'; visibleIf?: (n: N) => boolean } + | { key: keyof N; label?: string; kind: 'ref'; refKind: string; visibleIf?: (n: N) => boolean } /** Escape hatch for fields that don't map to a single node key — * derived values (`length` from `start`/`end`), sliders with * dynamic min/max (curve sagitta bounded by chord length), @@ -2322,6 +2329,7 @@ export type ParamField = * update logic. `key` here is just a stable React key/label. */ | { key: string + label?: string kind: 'custom' component: ComponentType<{ node: N; onUpdate: (patch: Partial) => void }> visibleIf?: (n: N) => boolean @@ -2373,6 +2381,19 @@ export type SceneApi = { nodes: () => Readonly> update: (id: AnyNodeId, patch: Partial) => void upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId + createMany?: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void + applyChanges?: (changes: { + create?: { node: AnyNode; parentId?: AnyNodeId }[] + update?: { id: AnyNodeId; data: Partial }[] + delete?: AnyNodeId[] + }) => void + subscribeNodes?: ( + listener: ( + nodes: Readonly>, + previous: Readonly>, + changedIds: ReadonlySet, + ) => void, + ) => () => void delete: (id: AnyNodeId) => void restore: (id: AnyNodeId) => void restoreAll: () => void diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 0fd4fb13bf..8596a79bde 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -33,6 +33,12 @@ export { resolveMaterial, TextureWrapMode, } from './material' +export { + type AutoDownspoutPlacement, + type AutomaticDownspoutInput, + planAutomaticDownspouts, + resolveAutomaticDownspoutLength, +} from './nodes/automatic-downspout' export { BlockEdge, BlockFace, @@ -101,7 +107,12 @@ export { type DormerSurfaceMaterialSpec, getEffectiveDormerSurfaceMaterial, } from './nodes/dormer' -export { DownspoutNode } from './nodes/downspout' +export { + DownspoutNode, + defaultDownspoutMetadata, + isDefaultDownspoutNode, + usesAutomaticDownspoutLength, +} from './nodes/downspout' export { DuctFittingNode } from './nodes/duct-fitting' export { DuctSegmentNode } from './nodes/duct-segment' export { DuctTerminalNode } from './nodes/duct-terminal' @@ -114,7 +125,22 @@ export { export { EyebrowVentNode } from './nodes/eyebrow-vent' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' -export { GutterNode, GutterOutlet } from './nodes/gutter' +export { + computeGutterEaveY, + createDefaultGuttersForSegment, + GUTTER_EAVE_TUCK_INWARD, + GUTTER_EAVE_TUCK_UP, + type GutterEaveSide, + type GutterEdgeExclusion, + GutterNode, + GutterOutlet, + type GutterRun, + getDefaultGutterSide, + getGutterRunsForSegment, + hasAutoGutterMetadata, + isAutoGutterEnabled, + isDefaultGutterNode, +} from './nodes/gutter' export { HvacEquipmentNode } from './nodes/hvac-equipment' export type { AnimationEffect, @@ -134,6 +160,13 @@ export { isLowProfileItemSurface, LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, } from './nodes/item' +export { + LeanToConnectionMode, + LeanToEndCondition, + LeanToExtensionNode, + LeanToResizeLock, + LeanToRoofEdge, +} from './nodes/lean-to-extension' export { LevelNode } from './nodes/level' export { LinesetNode } from './nodes/lineset' export { LiquidLineNode } from './nodes/liquid-line' @@ -182,6 +215,7 @@ export { getRoofSegmentVisibleTopBounds, getSegmentSlopeFrame, hasSegmentMaterialOverride, + isBandedShedSegment, MIN_ROOF_SEGMENT_TRIM_SPAN, normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, diff --git a/packages/core/src/schema/nodes/automatic-downspout.test.ts b/packages/core/src/schema/nodes/automatic-downspout.test.ts new file mode 100644 index 0000000000..58c6d1b7db --- /dev/null +++ b/packages/core/src/schema/nodes/automatic-downspout.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test' +import { planAutomaticDownspouts } from './automatic-downspout' +import { DownspoutNode } from './downspout' +import { GutterNode } from './gutter' +import { RoofSegmentNode } from './roof-segment' + +const segment = RoofSegmentNode.parse({ id: 'rseg_test' as never }) + +function gutter(id: string, position: [number, number, number], rotation: number, length: number) { + return GutterNode.parse({ + id: id as never, + roofSegmentId: segment.id, + position, + rotation, + length, + metadata: { generatedBy: 'default-gutter', autoGutterSide: '+Z' }, + }) +} + +describe('planAutomaticDownspouts', () => { + test('places one outlet near a free end of a short isolated gutter', () => { + const run = gutter('gutter_short', [0, 0, 3], 0, 6) + + const placements = planAutomaticDownspouts({ + segments: [segment], + gutters: [run], + downspouts: [], + }) + + expect(placements).toHaveLength(1) + expect(placements[0]?.gutterId).toBe(run.id) + expect(Math.abs(placements[0]?.offset ?? 0)).toBeCloseTo(2.84) + }) + + test('places downspouts at both free ends when an isolated gutter is too long', () => { + const run = gutter('gutter_long', [0, 0, 3], 0, 14) + + const placements = planAutomaticDownspouts({ + segments: [segment], + gutters: [run], + downspouts: [], + }) + + expect(placements).toHaveLength(2) + expect(placements.map((placement) => placement.offset).sort((a, b) => a - b)).toEqual([ + -6.84, 6.84, + ]) + }) + + test('does not place a downspout on a gutter connected at both ends', () => { + const left = gutter('gutter_left', [-4, 0, 3], 0, 4) + const middle = gutter('gutter_middle', [0, 0, 3], 0, 4) + const right = gutter('gutter_right', [4, 0, 3], 0, 4) + + const placements = planAutomaticDownspouts({ + segments: [segment], + gutters: [left, middle, right], + downspouts: [], + maxRunPerDownspout: 20, + }) + + expect(placements).toHaveLength(1) + expect(placements[0]?.gutterId).not.toBe(middle.id) + }) + + test('adds outlets to a closed loop even though it has no free ends', () => { + const gutters = [ + gutter('gutter_front', [0, 0, 3], 0, 6), + gutter('gutter_right', [3, 0, 0], Math.PI / 2, 6), + gutter('gutter_back', [0, 0, -3], Math.PI, 6), + gutter('gutter_left', [-3, 0, 0], -Math.PI / 2, 6), + ] + + const placements = planAutomaticDownspouts({ + segments: [segment], + gutters, + downspouts: [], + }) + + expect(placements).toHaveLength(3) + expect(new Set(placements.map((placement) => placement.gutterId)).size).toBe(3) + }) + + test('does not add an automatic downspout when a manual one already drains the component', () => { + const run = GutterNode.parse({ + ...gutter('gutter_manual_drop', [0, 0, 3], 0, 6), + outlets: [{ id: 'outlet_manual', offset: 2.5, diameter: 0.07 }], + }) + const downspout = DownspoutNode.parse({ + id: 'downspout_manual' as never, + gutterId: run.id, + outletId: 'outlet_manual', + }) + + expect( + planAutomaticDownspouts({ + segments: [segment], + gutters: [run], + downspouts: [downspout], + }), + ).toEqual([]) + }) +}) diff --git a/packages/core/src/schema/nodes/automatic-downspout.ts b/packages/core/src/schema/nodes/automatic-downspout.ts new file mode 100644 index 0000000000..007e06a74b --- /dev/null +++ b/packages/core/src/schema/nodes/automatic-downspout.ts @@ -0,0 +1,317 @@ +import { getWallBaseElevationForNodes } from '../../hooks/spatial-grid/spatial-grid-manager' +import { heightAt } from '../../lib/terrain-field' +import { persistedTerrainFieldOf } from '../../lib/terrain-source' +import { getLevelElevations } from '../../services/storey' +import type { AnyNode, AnyNodeId } from '../types' +import type { BuildingNode } from './building' +import type { DownspoutNode } from './downspout' +import { computeGutterEaveY, type GutterNode } from './gutter' +import type { LeanToExtensionNode } from './lean-to-extension' +import type { LevelNode } from './level' +import type { RoofNode } from './roof' +import type { RoofSegmentNode } from './roof-segment' +import type { SiteNode } from './site' +import type { WallNode } from './wall' + +const DEFAULT_MAX_RUN_PER_DOWNSPOUT_M = 10 +const OUTLET_END_INSET_M = 0.16 +const CONNECTION_TOLERANCE_M = 0.1 +const CONNECTION_TOLERANCE_SQ = CONNECTION_TOLERANCE_M * CONNECTION_TOLERANCE_M +const FLAT_GROUND_Y = 0 + +type Point2D = readonly [number, number] +type GutterEnd = { + gutterIndex: number + offset: number + point: Point2D +} + +export type AutoDownspoutPlacement = { + gutterId: GutterNode['id'] + offset: number +} + +export type AutomaticDownspoutInput = { + segments: readonly RoofSegmentNode[] + gutters: readonly GutterNode[] + downspouts: readonly DownspoutNode[] + maxRunPerDownspout?: number +} + +// A point on the gutter's own mesh, expressed in gutter-mesh-local +// coordinates: `alongX` is the signed distance from the gutter center along +// its length, `outwardZ` the outward offset from the eave line. For a curved +// run the flat (alongX, outwardZ) is bent onto the concentric arc descriptor +// (matches the mapping the outlet lookup + gutter geometry use); a straight +// gutter passes through unchanged. +function gutterMeshPoint(gutter: GutterNode, alongX: number, outwardZ: number): Point2D { + const arc = gutter.arc + if (!arc) return [alongX, outwardZ] + const signedRef = (Math.sign(arc.centerZ) || 1) * arc.radius + if (Math.abs(signedRef) < 1e-9) return [alongX, outwardZ] + const phi = (alongX - arc.centerX) / signedRef + const radial = outwardZ - arc.centerZ + return [arc.centerX - radial * Math.sin(phi), arc.centerZ + radial * Math.cos(phi)] +} + +function gutterPointInRoofFrame( + gutter: GutterNode, + segment: RoofSegmentNode | undefined, + offset: number, +): Point2D { + const gutterRotation = gutter.rotation ?? 0 + const [meshX, meshZ] = gutterMeshPoint(gutter, offset, 0) + const localX = + gutter.position[0] + Math.cos(gutterRotation) * meshX + Math.sin(gutterRotation) * meshZ + const localZ = + gutter.position[2] - Math.sin(gutterRotation) * meshX + Math.cos(gutterRotation) * meshZ + if (!segment) return [localX, localZ] + + const segmentRotation = segment.rotation ?? 0 + const cos = Math.cos(segmentRotation) + const sin = Math.sin(segmentRotation) + return [ + (segment.position?.[0] ?? 0) + localX * cos + localZ * sin, + (segment.position?.[2] ?? 0) - localX * sin + localZ * cos, + ] +} + +function rotateAndTranslate( + point: Point2D, + position: readonly [number, number, number] | undefined, + rotation: number, +): Point2D { + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [ + (position?.[0] ?? 0) + point[0] * cos + point[1] * sin, + (position?.[2] ?? 0) - point[0] * sin + point[1] * cos, + ] +} + +function gutterFloorMidZ(gutter: GutterNode): number { + const size = Math.max(0.04, gutter.size) + if (gutter.profile === 'half-round') return size + if (gutter.profile === 'box') return size / 2 + return size * 0.4 +} + +// The gutter's mount height in the host segment's local frame. Lean-to gutters +// can be raised to a shared eave line (stored as `leanToGutterEaveY` metadata by +// the lean-to assembly); the renderer mounts there rather than at the segment's +// own eave, so the outlet elevation must read the prescribed value when present. +function prescribedGutterEaveY(gutter: GutterNode): number | null { + const metadata = gutter.metadata + if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return null + const value = (metadata as Record).leanToGutterEaveY + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function resolveAutomaticDownspoutLength( + nodes: Record, + segment: RoofSegmentNode, + gutter: GutterNode, + outletOffset: number, +): number { + const roofCandidate = segment.parentId ? nodes[segment.parentId as AnyNodeId] : undefined + const roof = roofCandidate?.type === 'roof' ? (roofCandidate as RoofNode) : undefined + const roofParent = roof?.parentId ? nodes[roof.parentId as AnyNodeId] : undefined + const leanTo = + roofParent?.type === 'lean-to-extension' ? (roofParent as LeanToExtensionNode) : undefined + const wallCandidate = leanTo?.parentId ? nodes[leanTo.parentId as AnyNodeId] : undefined + const wall = wallCandidate?.type === 'wall' ? (wallCandidate as WallNode) : undefined + const levelCandidate = wall?.parentId ? nodes[wall.parentId as AnyNodeId] : roofParent + const level = levelCandidate?.type === 'level' ? (levelCandidate as LevelNode) : undefined + const buildingCandidate = level?.parentId ? nodes[level.parentId as AnyNodeId] : undefined + const building = + buildingCandidate?.type === 'building' ? (buildingCandidate as BuildingNode) : undefined + + const gutterRotation = gutter.rotation ?? 0 + const gutterFloorPoint = rotateAndTranslate( + gutterMeshPoint(gutter, outletOffset, gutterFloorMidZ(gutter)), + gutter.position, + gutterRotation, + ) + const roofPoint = rotateAndTranslate(gutterFloorPoint, segment.position, segment.rotation ?? 0) + const leanToPoint = rotateAndTranslate(roofPoint, roof?.position, roof?.rotation ?? 0) + const wallLocalPoint = leanTo + ? rotateAndTranslate(leanToPoint, leanTo.position, leanTo.rotation[1]) + : leanToPoint + const wallAngle = wall ? Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) : 0 + const levelPoint = wall + ? rotateAndTranslate(wallLocalPoint, [wall.start[0], 0, wall.start[1]], -wallAngle) + : wallLocalPoint + const buildingRotation = building?.rotation?.[1] ?? 0 + const worldPoint = rotateAndTranslate(levelPoint, building?.position, buildingRotation) + + const site = Object.values(nodes).find((node): node is SiteNode => node?.type === 'site') + const terrain = persistedTerrainFieldOf(site) + const groundY = terrain ? heightAt(terrain, worldPoint[0], worldPoint[1]) : FLAT_GROUND_Y + const levelBaseY = level ? (getLevelElevations(nodes).get(level.id)?.baseY ?? 0) : 0 + const outletWorldY = + (building?.position?.[1] ?? 0) + + levelBaseY + + (wall ? getWallBaseElevationForNodes(wall, nodes) : 0) + + (leanTo?.position[1] ?? 0) + + (roof?.position?.[1] ?? 0) + + (segment.position?.[1] ?? 0) + + (prescribedGutterEaveY(gutter) ?? computeGutterEaveY(segment)) - + Math.max(0.04, gutter.size) + + return Math.max(0.1, outletWorldY - groundY) +} + +function distanceSquared(a: Point2D, b: Point2D) { + return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 +} + +function find(parent: number[], value: number): number { + let root = value + while (parent[root] !== root) root = parent[root]! + while (parent[value] !== value) { + const next = parent[value]! + parent[value] = root + value = next + } + return root +} + +function union(parent: number[], a: number, b: number) { + const rootA = find(parent, a) + const rootB = find(parent, b) + if (rootA !== rootB) parent[rootB] = rootA +} + +function addUniquePlacement( + placements: AutoDownspoutPlacement[], + seen: Set, + gutter: GutterNode, + offset: number, +) { + const key = `${gutter.id}:${offset.toFixed(6)}` + if (seen.has(key)) return + seen.add(key) + placements.push({ gutterId: gutter.id, offset }) +} + +export function planAutomaticDownspouts({ + segments, + gutters, + downspouts, + maxRunPerDownspout = DEFAULT_MAX_RUN_PER_DOWNSPOUT_M, +}: AutomaticDownspoutInput): AutoDownspoutPlacement[] { + if (gutters.length === 0) return [] + + const segmentById = new Map( + segments.map((segment) => [segment.id, segment]), + ) + const parent = gutters.map((_, index) => index) + const ends: GutterEnd[] = [] + for (let gutterIndex = 0; gutterIndex < gutters.length; gutterIndex++) { + const gutter = gutters[gutterIndex]! + const halfLength = Math.max(0, gutter.length) / 2 + const segment = gutter.roofSegmentId ? segmentById.get(gutter.roofSegmentId) : undefined + ends.push( + { + gutterIndex, + offset: halfLength, + point: gutterPointInRoofFrame(gutter, segment, halfLength), + }, + { + gutterIndex, + offset: -halfLength, + point: gutterPointInRoofFrame(gutter, segment, -halfLength), + }, + ) + } + + const connectedEnds = new Set() + for (let i = 0; i < ends.length; i++) { + for (let j = i + 1; j < ends.length; j++) { + const a = ends[i]! + const b = ends[j]! + if (a.gutterIndex === b.gutterIndex) continue + if (distanceSquared(a.point, b.point) > CONNECTION_TOLERANCE_SQ) continue + connectedEnds.add(i) + connectedEnds.add(j) + union(parent, a.gutterIndex, b.gutterIndex) + } + } + + const componentIndices = new Map() + for (let index = 0; index < gutters.length; index++) { + const root = find(parent, index) + const indices = componentIndices.get(root) ?? [] + indices.push(index) + componentIndices.set(root, indices) + } + + const gutterIndexById = new Map( + gutters.map((gutter, index) => [gutter.id, index]), + ) + const placements: AutoDownspoutPlacement[] = [] + const seen = new Set() + const safeMaxRun = Math.max(0.5, maxRunPerDownspout) + + for (const indices of componentIndices.values()) { + const indexSet = new Set(indices) + const totalLength = indices.reduce( + (sum, index) => sum + Math.max(0, gutters[index]?.length ?? 0), + 0, + ) + const requiredCount = Math.max(1, Math.ceil(totalLength / safeMaxRun)) + const manualCount = downspouts.filter((downspout) => { + if (!downspout.gutterId) return false + const gutterIndex = gutterIndexById.get(downspout.gutterId) + if (gutterIndex === undefined || !indexSet.has(gutterIndex)) return false + const gutter = gutters[gutterIndex] + return Boolean( + gutter && + downspout.outletId && + (gutter.outlets ?? []).some((outlet) => outlet.id === downspout.outletId), + ) + }).length + let remaining = Math.max(0, requiredCount - manualCount) + if (remaining === 0) continue + + const freeEnds = ends.filter( + (end, endIndex) => indexSet.has(end.gutterIndex) && !connectedEnds.has(endIndex), + ) + for (const end of freeEnds) { + if (remaining === 0) break + const gutter = gutters[end.gutterIndex]! + const bound = Math.max(0, gutter.length / 2 - OUTLET_END_INSET_M) + addUniquePlacement(placements, seen, gutter, end.offset >= 0 ? bound : -bound) + remaining-- + } + + if (remaining === 0) continue + + const componentGutters = indices + .map((index) => gutters[index]!) + .sort((a, b) => b.length - a.length || a.id.localeCompare(b.id)) + const interiorCandidates: AutoDownspoutPlacement[] = [] + let round = 0 + while (interiorCandidates.length < remaining) { + let addedThisRound = 0 + for (const gutter of componentGutters) { + const interiorSlots = Math.max(1, Math.ceil(gutter.length / safeMaxRun) - 1) + if (round >= interiorSlots) continue + const offset = -gutter.length / 2 + (gutter.length * (round + 1)) / (interiorSlots + 1) + interiorCandidates.push({ gutterId: gutter.id, offset }) + addedThisRound++ + } + if (addedThisRound === 0) break + round++ + } + + for (const candidate of interiorCandidates) { + if (remaining === 0) break + const gutter = gutters[gutterIndexById.get(candidate.gutterId)!]! + addUniquePlacement(placements, seen, gutter, candidate.offset) + remaining-- + } + } + + return placements +} diff --git a/packages/core/src/schema/nodes/downspout.ts b/packages/core/src/schema/nodes/downspout.ts index e3c1de8f1e..174e3de092 100644 --- a/packages/core/src/schema/nodes/downspout.ts +++ b/packages/core/src/schema/nodes/downspout.ts @@ -3,6 +3,8 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +const DEFAULT_DOWNSPOUT_GENERATOR = 'default-downspout' + export const DownspoutNode = BaseNode.extend({ id: objectId('downspout'), type: nodeType('downspout'), @@ -30,6 +32,7 @@ export const DownspoutNode = BaseNode.extend({ // tool can default to the gutter's eave-Y minus building floor on // commit so the user doesn't have to set it on every drop. length: z.number().default(2.5), + lengthMode: z.enum(['to-ground', 'manual']).optional(), // Bore diameter, default 0.07 m ≈ 3″ to match the gutter outlet // default. Larger downspouts are common on commercial gutters. diameter: z.number().default(0.07), @@ -72,3 +75,28 @@ export const DownspoutNode = BaseNode.extend({ ) export type DownspoutNode = z.infer + +function metadataRecord(metadata: unknown): Record { + if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) { + return metadata as Record + } + return {} +} + +export function defaultDownspoutMetadata() { + return { generatedBy: DEFAULT_DOWNSPOUT_GENERATOR } +} + +export function isDefaultDownspoutNode(node: unknown, gutterId?: string): node is DownspoutNode { + const parsed = DownspoutNode.safeParse(node) + if (!parsed.success) return false + if (gutterId && parsed.data.gutterId !== gutterId) return false + return metadataRecord(parsed.data.metadata).generatedBy === DEFAULT_DOWNSPOUT_GENERATOR +} + +export function usesAutomaticDownspoutLength(node: DownspoutNode): boolean { + return ( + node.lengthMode === 'to-ground' || + (node.lengthMode === undefined && isDefaultDownspoutNode(node)) + ) +} diff --git a/packages/core/src/schema/nodes/gutter-defaults.test.ts b/packages/core/src/schema/nodes/gutter-defaults.test.ts new file mode 100644 index 0000000000..40a503cb4f --- /dev/null +++ b/packages/core/src/schema/nodes/gutter-defaults.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'bun:test' +import { + computeGutterEaveY, + createDefaultGuttersForSegment, + getDefaultGutterSide, + getGutterRunsForSegment, + isAutoGutterEnabled, + isDefaultGutterNode, +} from './gutter' +import { RoofSegmentNode, type RoofType } from './roof-segment' + +describe('createDefaultGuttersForSegment', () => { + test.each([ + ['shed', ['+Z']], + ['gable', ['+Z', '-Z']], + ['gambrel', ['+Z', '-Z']], + ['hip', ['+Z', '-Z', '+X', '-X']], + ['dutch', ['+Z', '-Z', '+X', '-X']], + ['mansard', ['+Z', '-Z', '+X', '-X']], + ['flat', ['+Z', '-Z', '+X', '-X']], + ] satisfies [RoofType, string[]][])('creates the expected %s roof eaves', (roofType, sides) => { + const segment = RoofSegmentNode.parse({ roofType, width: 8, depth: 6 }) + const gutters = createDefaultGuttersForSegment(segment) + + expect(gutters.map((gutter) => getDefaultGutterSide(gutter, segment.id))).toEqual(sides) + expect(gutters.every((gutter) => isDefaultGutterNode(gutter, segment.id))).toBe(true) + }) + + test('spans the full tucked perimeter so four-sided gutters meet at corners', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'flat', + width: 8, + depth: 6, + overhang: 0.3, + wallHeight: 0.5, + }) + const runs = getGutterRunsForSegment(segment) + const front = runs.find((run) => run.side === '+Z') + const right = runs.find((run) => run.side === '+X') + + expect(front?.position).toEqual([0, 0.5, 3.26]) + expect(front?.length).toBeCloseTo(8.52) + expect(right?.position).toEqual([4.26, 0.5, 0]) + expect(right?.length).toBeCloseTo(6.52) + }) + + test('omits fully trimmed sides and shortens their adjacent eaves', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'flat', + width: 8, + depth: 6, + overhang: 0.3, + trim: { left: 1, front: 1 }, + }) + const runs = getGutterRunsForSegment(segment) + + expect(runs.map((run) => run.side)).toEqual(['-Z', '+X']) + expect(runs.find((run) => run.side === '-Z')?.length).toBeCloseTo(7.26) + expect(runs.find((run) => run.side === '+X')?.length).toBeCloseTo(5.26) + }) + + test('splits an eave around an intersecting sibling roof segment', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_main' as never, + roofType: 'gable', + width: 8, + depth: 6, + overhang: 0.3, + }) + const sibling = RoofSegmentNode.parse({ + id: 'rseg_cross' as never, + roofType: 'gable', + width: 6, + depth: 4, + overhang: 0.3, + position: [0, 0, 3.26], + rotation: Math.PI / 2, + }) + + const frontRuns = getGutterRunsForSegment(segment, [segment, sibling]).filter( + (run) => run.side === '+Z', + ) + + expect(frontRuns).toHaveLength(2) + expect(frontRuns[0]?.position[0]).toBeCloseTo(-3.26) + expect(frontRuns[1]?.position[0]).toBeCloseTo(3.26) + expect(frontRuns[0]?.length).toBeCloseTo(2) + expect(frontRuns[1]?.length).toBeCloseTo(2) + }) + + test('splits an eave around an attached roof-extension range', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'shed', + width: 8, + depth: 6, + overhang: 0.3, + }) + const fullRun = getGutterRunsForSegment(segment)[0]! + const runs = getGutterRunsForSegment(segment, [], [{ side: '+Z', from: 0.25, to: 0.75 }]) + + expect(runs).toHaveLength(2) + expect(runs[0]?.length).toBeCloseTo(fullRun.length * 0.25) + expect(runs[1]?.length).toBeCloseTo(fullRun.length * 0.25) + expect(runs[0]?.position[0]).toBeLessThan(0) + expect(runs[1]?.position[0]).toBeGreaterThan(0) + }) + + test('omits an eave fully occupied by an attached roof extension', () => { + const segment = RoofSegmentNode.parse({ roofType: 'shed', width: 8, depth: 6 }) + + expect(getGutterRunsForSegment(segment, [], [{ side: '+Z', from: 0, to: 1 }])).toHaveLength(0) + }) + + test('keeps flat gutters on the deck and sloped gutters at the live eave height', () => { + expect( + computeGutterEaveY({ roofType: 'flat', wallHeight: 0.6, overhang: 0.3, pitch: 40 }), + ).toBeCloseTo(0.6) + expect( + computeGutterEaveY({ roofType: 'gable', wallHeight: 0.6, overhang: 0.3, pitch: 45 }), + ).toBeCloseTo(0.34) + }) + + test('infers auto mode from generated children when explicit metadata is absent', () => { + const segment = RoofSegmentNode.parse({ roofType: 'gable' }) + const gutters = createDefaultGuttersForSegment(segment) + const nodes = Object.fromEntries(gutters.map((gutter) => [gutter.id, gutter])) + + expect( + isAutoGutterEnabled( + { id: segment.id, children: gutters.map((gutter) => gutter.id), metadata: {} }, + nodes, + ), + ).toBe(true) + }) +}) diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts index d9fbc7e44e..5852ebb9c4 100644 --- a/packages/core/src/schema/nodes/gutter.ts +++ b/packages/core/src/schema/nodes/gutter.ts @@ -2,6 +2,31 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +import { normalizeRoofSegmentTrim, type RoofSegmentNode } from './roof-segment' + +const MIN_DEFAULT_GUTTER_LENGTH_M = 0.2 +const DEFAULT_GUTTER_GENERATOR = 'default-gutter' +const AUTO_GUTTER_METADATA_KEY = 'autoGutter' + +export const GUTTER_EAVE_TUCK_INWARD = 0.04 +export const GUTTER_EAVE_TUCK_UP = 0.04 +export type GutterEaveSide = '+X' | '-X' | '+Z' | '-Z' + +export type GutterRun = { + side: GutterEaveSide + position: [number, number, number] + rotation: number + length: number +} + +export type GutterEdgeExclusion = { + side: GutterEaveSide + from: number + to: number +} + +type Point2D = readonly [number, number] +type Interval = readonly [number, number] // A single drop outlet drilled in the gutter floor. A gutter can carry // several so a long run can split between multiple downspouts (each @@ -16,6 +41,7 @@ export const GutterOutlet = z.object({ // Bore diameter of this drop. Default 0.07 m ≈ 3″. The cross-section // SHAPE (round vs rectangular) follows the gutter's profile, not this. diameter: z.number().default(0.07), + generatedBy: z.literal('default-downspout').optional(), }) export type GutterOutlet = z.infer @@ -41,8 +67,19 @@ export const GutterNode = BaseNode.extend({ // to tilt for a custom run. rotation: z.number().default(0), - // Length along the eave (gutter-local +X). + // Length along the eave (gutter-local +X). For a curved eave this is the + // arc length of the run. length: z.number().default(2.0), + // Concentric-arc descriptor for a run that follows a curved eave, in + // gutter-mesh-local coordinates (center + true radius). Absent for a straight + // gutter. + arc: z + .object({ + centerX: z.number(), + centerZ: z.number(), + radius: z.number(), + }) + .optional(), // Profile size — the vertical drop of the U-channel below the eave // line. 5″ (0.127 m) is the most common residential gutter size; 6″ // (0.152 m) is the common commercial / heavy-duty size. Default @@ -88,3 +125,351 @@ export const GutterNode = BaseNode.extend({ ) export type GutterNode = z.infer + +function metadataRecord(metadata: unknown): Record { + if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) { + return metadata as Record + } + return {} +} + +export function computeGutterEaveY( + segment: Pick, +): number { + const wallHeight = segment.wallHeight ?? 0 + if ((segment.roofType ?? 'gable') === 'flat') return wallHeight + const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 + return wallHeight - (segment.overhang ?? 0) * Math.tan(pitchRad) + GUTTER_EAVE_TUCK_UP +} + +function getDefaultGutterSides(segment: RoofSegmentNode): GutterEaveSide[] { + switch (segment.roofType) { + case 'shed': + return ['+Z'] + case 'gable': + case 'gambrel': + return ['+Z', '-Z'] + default: + return ['+Z', '-Z', '+X', '-X'] + } +} + +function getGutterEnvelope(segment: RoofSegmentNode) { + const halfW = Math.max(0, segment.width) / 2 + const halfD = Math.max(0, segment.depth) / 2 + const overhang = Math.max(0, segment.overhang ?? 0) + const outerHalfW = Math.max(halfW, halfW + overhang - GUTTER_EAVE_TUCK_INWARD) + const outerHalfD = Math.max(halfD, halfD + overhang - GUTTER_EAVE_TUCK_INWARD) + const trim = normalizeRoofSegmentTrim(segment) + const minX = trim.left > 0 ? -halfW + trim.left : -outerHalfW + const maxX = trim.right > 0 ? halfW - trim.right : outerHalfW + const minZ = trim.back > 0 ? -halfD + trim.back : -outerHalfD + const maxZ = trim.front > 0 ? halfD - trim.front : outerHalfD + + return { minX, maxX, minZ, maxZ, outerHalfW, outerHalfD, trim } +} + +function getGutterEnvelopePolygon(segment: RoofSegmentNode): Point2D[] { + const { minX, maxX, minZ, maxZ, trim } = getGutterEnvelope(segment) + return [ + [minX + trim.backLeftX, minZ], + [maxX - trim.backRightX, minZ], + [maxX, minZ + trim.backRightZ], + [maxX, maxZ - trim.frontRightZ], + [maxX - trim.frontRightX, maxZ], + [minX + trim.frontLeftX, maxZ], + [minX, maxZ - trim.frontLeftZ], + [minX, minZ + trim.backLeftZ], + ] +} + +function segmentLocalToRoof(segment: RoofSegmentNode, point: Point2D): Point2D { + const rotation = segment.rotation ?? 0 + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [ + (segment.position?.[0] ?? 0) + point[0] * cos + point[1] * sin, + (segment.position?.[2] ?? 0) - point[0] * sin + point[1] * cos, + ] +} + +function pointOnSegment(point: Point2D, a: Point2D, b: Point2D): boolean { + const lengthSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 + if (lengthSq <= 1e-14) { + return (point[0] - a[0]) ** 2 + (point[1] - a[1]) ** 2 <= 1e-14 + } + const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]) + if (Math.abs(cross) > 1e-7) return false + const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]) + if (dot < -1e-7) return false + return dot <= lengthSq + 1e-7 +} + +function pointStrictlyInsidePolygon(point: Point2D, polygon: readonly Point2D[]): boolean { + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const a = polygon[j] as Point2D + const b = polygon[i] as Point2D + if (pointOnSegment(point, a, b)) return false + if ( + a[1] > point[1] !== b[1] > point[1] && + point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0] + ) { + inside = !inside + } + } + return inside +} + +function segmentCrossingT(start: Point2D, end: Point2D, a: Point2D, b: Point2D) { + const rx = end[0] - start[0] + const rz = end[1] - start[1] + const sx = b[0] - a[0] + const sz = b[1] - a[1] + const denominator = rx * sz - rz * sx + if (Math.abs(denominator) < 1e-10) return null + const dx = a[0] - start[0] + const dz = a[1] - start[1] + const t = (dx * sz - dz * sx) / denominator + const u = (dx * rz - dz * rx) / denominator + if (t < -1e-8 || t > 1 + 1e-8 || u < -1e-8 || u > 1 + 1e-8) return null + return Math.max(0, Math.min(1, t)) +} + +function coveredIntervals(start: Point2D, end: Point2D, polygon: readonly Point2D[]): Interval[] { + const splits = [0, 1] + for (let i = 0; i < polygon.length; i++) { + const t = segmentCrossingT( + start, + end, + polygon[i] as Point2D, + polygon[(i + 1) % polygon.length]!, + ) + if (t !== null) splits.push(t) + } + splits.sort((a, b) => a - b) + + const unique = splits.filter((value, index) => index === 0 || value - splits[index - 1]! > 1e-7) + const intervals: Interval[] = [] + for (let i = 0; i < unique.length - 1; i++) { + const from = unique[i]! + const to = unique[i + 1]! + if (to - from <= 1e-7) continue + const middle = (from + to) / 2 + const point: Point2D = [ + start[0] + (end[0] - start[0]) * middle, + start[1] + (end[1] - start[1]) * middle, + ] + if (pointStrictlyInsidePolygon(point, polygon)) intervals.push([from, to]) + } + return intervals +} + +function subtractInterval(visible: readonly Interval[], covered: Interval): Interval[] { + const next: Interval[] = [] + for (const [from, to] of visible) { + if (covered[1] <= from + 1e-7 || covered[0] >= to - 1e-7) { + next.push([from, to]) + continue + } + if (covered[0] > from + 1e-7) next.push([from, Math.min(to, covered[0])]) + if (covered[1] < to - 1e-7) next.push([Math.max(from, covered[1]), to]) + } + return next +} + +function clipRunAgainstSegments( + run: GutterRun, + segment: RoofSegmentNode, + roofSegments: readonly RoofSegmentNode[], +): GutterRun[] { + const direction: Point2D = [Math.cos(run.rotation), -Math.sin(run.rotation)] + const localStart: Point2D = [ + run.position[0] - direction[0] * (run.length / 2), + run.position[2] - direction[1] * (run.length / 2), + ] + const localEnd: Point2D = [ + run.position[0] + direction[0] * (run.length / 2), + run.position[2] + direction[1] * (run.length / 2), + ] + const roofStart = segmentLocalToRoof(segment, localStart) + const roofEnd = segmentLocalToRoof(segment, localEnd) + let visible: Interval[] = [[0, 1]] + + for (const sibling of roofSegments) { + if (sibling.id === segment.id) continue + const polygon = getGutterEnvelopePolygon(sibling).map((point) => + segmentLocalToRoof(sibling, point), + ) + for (const covered of coveredIntervals(roofStart, roofEnd, polygon)) { + visible = subtractInterval(visible, covered) + } + if (visible.length === 0) break + } + + return visible + .map(([from, to]) => { + const length = run.length * (to - from) + const middle = (from + to) / 2 + return { + ...run, + position: [ + localStart[0] + (localEnd[0] - localStart[0]) * middle, + run.position[1], + localStart[1] + (localEnd[1] - localStart[1]) * middle, + ] as [number, number, number], + length, + } + }) + .filter((candidate) => candidate.length >= MIN_DEFAULT_GUTTER_LENGTH_M) +} + +function clipRunAgainstExclusions( + run: GutterRun, + exclusions: readonly GutterEdgeExclusion[], +): GutterRun[] { + let visible: Interval[] = [[0, 1]] + for (const exclusion of exclusions) { + if (exclusion.side !== run.side) continue + const from = Math.max(0, Math.min(1, Math.min(exclusion.from, exclusion.to))) + const to = Math.max(0, Math.min(1, Math.max(exclusion.from, exclusion.to))) + visible = subtractInterval(visible, [from, to]) + if (visible.length === 0) break + } + + const direction: Point2D = [Math.cos(run.rotation), -Math.sin(run.rotation)] + const start: Point2D = [ + run.position[0] - direction[0] * (run.length / 2), + run.position[2] - direction[1] * (run.length / 2), + ] + return visible + .map(([from, to]) => { + const length = run.length * (to - from) + const middle = (from + to) / 2 + return { + ...run, + position: [ + start[0] + direction[0] * run.length * middle, + run.position[1], + start[1] + direction[1] * run.length * middle, + ] as [number, number, number], + length, + } + }) + .filter((candidate) => candidate.length >= MIN_DEFAULT_GUTTER_LENGTH_M) +} + +export function getGutterRunsForSegment( + segment: RoofSegmentNode, + roofSegments: readonly RoofSegmentNode[] = [], + exclusions: readonly GutterEdgeExclusion[] = [], +): GutterRun[] { + const { minX, maxX, minZ, maxZ, outerHalfW, outerHalfD, trim } = getGutterEnvelope(segment) + const eaveY = computeGutterEaveY(segment) + + const runs: Record = { + '+Z': + trim.front > 0 + ? null + : { + side: '+Z', + position: [(minX + maxX) / 2, eaveY, outerHalfD], + rotation: 0, + length: maxX - minX, + }, + '-Z': + trim.back > 0 + ? null + : { + side: '-Z', + position: [(minX + maxX) / 2, eaveY, -outerHalfD], + rotation: Math.PI, + length: maxX - minX, + }, + '+X': + trim.right > 0 + ? null + : { + side: '+X', + position: [outerHalfW, eaveY, (minZ + maxZ) / 2], + rotation: Math.PI / 2, + length: maxZ - minZ, + }, + '-X': + trim.left > 0 + ? null + : { + side: '-X', + position: [-outerHalfW, eaveY, (minZ + maxZ) / 2], + rotation: -Math.PI / 2, + length: maxZ - minZ, + }, + } + + const candidates = getDefaultGutterSides(segment) + .map((side) => runs[side]) + .filter((run): run is GutterRun => run !== null && run.length >= MIN_DEFAULT_GUTTER_LENGTH_M) + + return candidates + .flatMap((run) => clipRunAgainstExclusions(run, exclusions)) + .flatMap((run) => clipRunAgainstSegments(run, segment, roofSegments)) +} + +export function createDefaultGuttersForSegment( + segment: RoofSegmentNode, + roofSegments: readonly RoofSegmentNode[] = [], + exclusions: readonly GutterEdgeExclusion[] = [], +): GutterNode[] { + return getGutterRunsForSegment(segment, roofSegments, exclusions).map((run) => + GutterNode.parse({ + name: 'Gutter', + roofSegmentId: segment.id, + position: run.position, + rotation: run.rotation, + length: run.length, + metadata: { + generatedBy: DEFAULT_GUTTER_GENERATOR, + autoGutterSide: run.side, + }, + }), + ) +} + +export function getDefaultGutterSide( + node: unknown, + roofSegmentId?: RoofSegmentNode['id'], +): GutterEaveSide | null { + const parsed = GutterNode.safeParse(node) + if (!parsed.success) return null + if (roofSegmentId && parsed.data.roofSegmentId !== roofSegmentId) return null + const metadata = metadataRecord(parsed.data.metadata) + if (metadata.generatedBy !== DEFAULT_GUTTER_GENERATOR) return null + const side = metadata.autoGutterSide + return side === '+X' || side === '-X' || side === '+Z' || side === '-Z' ? side : null +} + +export function isDefaultGutterNode( + node: unknown, + roofSegmentId?: RoofSegmentNode['id'], +): node is GutterNode { + return getDefaultGutterSide(node, roofSegmentId) !== null +} + +export function hasAutoGutterMetadata(segment: Pick): segment is Pick< + RoofSegmentNode, + 'metadata' +> & { + metadata: Record & { autoGutter: boolean } +} { + return typeof metadataRecord(segment.metadata)[AUTO_GUTTER_METADATA_KEY] === 'boolean' +} + +export function isAutoGutterEnabled( + segment: Pick, + nodes?: Record, +): boolean { + const metadataValue = metadataRecord(segment.metadata)[AUTO_GUTTER_METADATA_KEY] + if (typeof metadataValue === 'boolean') return metadataValue + if (!nodes) return false + return (segment.children ?? []).some((childId) => isDefaultGutterNode(nodes[childId], segment.id)) +} diff --git a/packages/core/src/schema/nodes/lean-to-extension.ts b/packages/core/src/schema/nodes/lean-to-extension.ts new file mode 100644 index 0000000000..127e3bdb8d --- /dev/null +++ b/packages/core/src/schema/nodes/lean-to-extension.ts @@ -0,0 +1,116 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { ColumnNode } from './column' +import { RoofNode } from './roof' + +export const LeanToConnectionMode = z.enum(['auto', 'manual']) +export const LeanToRoofEdge = z.enum(['+X', '-X', '+Z', '-Z']) +export const LeanToResizeLock = z.enum([ + 'preserve-high-edge', + 'preserve-low-edge', + 'preserve-pitch', +]) +export const LeanToEndCondition = z.enum(['open', 'wall-abutment', 'joined']) +export const LeanToFramingStrategy = z.enum(['hidden', 'rafters', 'purlins', 'covering-specific']) +export const LeanToHighSideMode = z.enum(['wall-ledger', 'independent-high-beam']) +export const LeanToPostLayoutMode = z.enum(['count', 'target-spacing']) +export const LeanToFootingStyle = z.enum(['none', 'base-plate', 'concrete-pad']) +export const LeanToCoveringType = z.enum(['generic', 'shingle', 'metal-panel']) +const DEFAULT_LOW_EDGE_HEIGHT = 2.7 - 3 * Math.tan((5 * Math.PI) / 180) +const DEFAULT_LEAN_TO_POST_SPACING = 3 +export type LeanToConnectionMode = z.infer +export type LeanToRoofEdge = z.infer + +export const LeanToExtensionNode = BaseNode.extend({ + id: objectId('leanto'), + type: nodeType('lean-to-extension'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + children: z.array(z.union([ColumnNode.shape.id, RoofNode.shape.id])).default([]), + + span: z.number().min(0.5).max(100).default(4), + autoSpan: z.boolean().default(true), + projection: z.number().min(0.5).max(10).default(2.5), + spanArcCenterZ: z + .number() + .optional() + .describe( + 'Local-Z of the host wall arc center in the lean-to local frame (the crown sits on the local Z axis, so center X = 0). Derived from the host wall arc; absent for straight walls.', + ), + spanArcRadius: z + .number() + .optional() + .describe( + "The host wall's true arc radius, in metres. Derived from the host wall arc; absent for straight walls.", + ), + highEdgeHeight: z.number().min(0.8).max(10).default(2.8), + lowEdgeHeight: z.number().min(0.2).max(10).default(DEFAULT_LOW_EDGE_HEIGHT), + pitch: z.number().min(1).max(45).default(10), + resizeLock: LeanToResizeLock.default('preserve-high-edge'), + leftEndCondition: LeanToEndCondition.default('open'), + rightEndCondition: LeanToEndCondition.default('open'), + autoMiterCorners: z.boolean().default(true), + sideFlashing: z.boolean().default(true), + flashingProjection: z.number().min(0.01).max(0.5).default(0.025), + flashingHeight: z.number().min(0.03).max(0.5).default(0.14), + slots: z.record(z.string(), z.string()).optional(), + + highSideMode: LeanToHighSideMode.default('wall-ledger'), + ledgerVerticalOffset: z.number().min(-1).max(1).default(0), + lowBeamInset: z.number().min(0).max(2).default(0), + + gutterEnabled: z.boolean().default(true), + gutterProfile: z.enum(['k-style', 'half-round', 'box']).default('k-style'), + gutterSize: z.number().min(0.04).max(0.3).default(0.13), + downspoutEnabled: z.boolean().default(true), + downspoutPosition: z.number().min(-1).max(1).default(1), + + connectionMode: LeanToConnectionMode.default('auto'), + hostRoofId: RoofNode.shape.id.optional(), + hostRoofSegmentId: z.string().optional(), + hostRoofEdge: LeanToRoofEdge.optional(), + hostRoofEdgeRange: z.tuple([z.number().min(0).max(1), z.number().min(0).max(1)]).optional(), + connectionOffset: z.number().min(-1).max(1).default(0), + connectionInset: z.number().min(0).max(10).default(0), + matchHostRoofMaterial: z.boolean().default(true), + matchHostRoofStructure: z.boolean().default(true), + + roofThickness: z.number().min(0.02).max(0.5).default(0.1), + shingleThickness: z.number().min(0).max(0.5).default(0.025), + highOverhang: z.number().min(0).max(1.5).default(0), + lowOverhang: z.number().min(0).max(1.5).default(0.25), + leftOverhang: z.number().min(0).max(1.5).default(0.15), + rightOverhang: z.number().min(0).max(1.5).default(0.15), + coveringType: LeanToCoveringType.default('generic'), + beamWidth: z.number().min(0.05).max(0.6).default(0.16), + beamHeight: z.number().min(0.05).max(0.8).default(0.24), + ledgerDepth: z.number().min(0.03).max(0.5).default(0.1), + ledgerHeight: z.number().min(0.05).max(0.8).default(0.18), + rafterWidth: z.number().min(0.03).max(0.4).default(0.08), + rafterHeight: z.number().min(0.03).max(0.5).default(0.14), + rafterSpacing: z.number().min(0.2).max(3).default(1.2), + rafterEndInset: z.number().min(0).max(3).default(0), + framingStrategy: LeanToFramingStrategy.default('rafters'), + purlinWidth: z.number().min(0.03).max(0.4).default(0.08), + purlinHeight: z.number().min(0.03).max(0.5).default(0.1), + purlinSpacing: z.number().min(0.2).max(3).default(0.8), + postWidth: z.number().min(0.05).max(0.6).default(0.16), + postDepth: z.number().min(0.05).max(0.6).default(0.16), + postCount: z.number().int().min(2).max(20).default(3), + postLayoutMode: LeanToPostLayoutMode.default('target-spacing'), + postSpacing: z.number().min(0.3).max(10).default(DEFAULT_LEAN_TO_POST_SPACING), + postInset: z.number().min(0).max(3).default(0), + postBracing: z.enum(['none', 'knee']).default('none'), + footingStyle: LeanToFootingStyle.default('none'), +}).describe( + dedent` + Wall-hosted lean-to roof extension. + The high edge attaches to the host wall and the mono-pitch roof falls along + local +Z to a beam supported by a managed row of column children. Its roof is a standard + shed roof segment with standard gutter and downspout children. It is an open canopy, not a + standalone enclosed shed roof. + `, +) + +export type LeanToExtensionNode = z.infer diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 2f13fdc2ac..7e5a5ecd6b 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -122,6 +122,21 @@ export const RoofSegmentNode = BaseNode.extend({ deckThickness: z.number().default(0.1), overhang: z.number().default(0.3), shingleThickness: z.number().default(0.05), + arc: z + .object({ + centerX: z.number(), + centerZ: z.number(), + radius: z.number(), + }) + .optional() + .describe( + 'Concentric-arc descriptor for a curved shed deck, in segment-local coordinates (center + true radius). Absent for a straight deck.', + ), + shedSideInfillSpan: z.number().positive().optional(), + shedSideInfillMinX: z.number().optional(), + shedSideInfillMaxX: z.number().optional(), + shedFootprintPieces: z.array(z.array(z.tuple([z.number(), z.number()])).min(3)).optional(), + shedOpenEndSides: z.array(z.enum(['left', 'right'])).optional(), // Shape-specific ratios. Only the pair matching `roofType` is read; the // rest are inert. Defined on every segment so the panel can flip // roofType without losing the previous shape's tuning. @@ -664,6 +679,16 @@ export function getRoofSegmentSurfaceY( return peakY - t * activeRh } +// A shed segment whose deck follows a concentric arc. +export function isBandedShedSegment(node: Pick): node is Pick< + RoofSegmentNode, + 'roofType' | 'arc' +> & { + arc: NonNullable +} { + return node.roofType === 'shed' && node.arc != null && Number.isFinite(node.arc.radius) +} + /** * Inverse of `getActiveRoofHeight` — recover the pitch a legacy * `roofHeight` value would correspond to. Used by the scene migration. diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index d4afd49ffd..c04406f7fe 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -4,6 +4,7 @@ import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' import { DoorNode } from './door' import { ItemNode } from './item' +import { LeanToExtensionNode } from './lean-to-extension' import { WindowNode } from './window' export const WallTreatmentSide = z.enum(['interior', 'exterior', 'both']) @@ -131,7 +132,14 @@ export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), children: z - .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) + .array( + z.union([ + ItemNode.shape.id, + DoorNode.shape.id, + WindowNode.shape.id, + LeanToExtensionNode.shape.id, + ]), + ) .default([]), // Legacy single-material wall finish. Read for backward compatibility only. material: MaterialSchema.optional(), diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index b4467ad170..ab79a82ead 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -21,6 +21,7 @@ import { GuideNode } from './nodes/guide' import { GutterNode } from './nodes/gutter' import { HvacEquipmentNode } from './nodes/hvac-equipment' import { ItemNode } from './nodes/item' +import { LeanToExtensionNode } from './nodes/lean-to-extension' import { LevelNode } from './nodes/level' import { LinesetNode } from './nodes/lineset' import { LiquidLineNode } from './nodes/liquid-line' @@ -51,6 +52,7 @@ export const AnyNode = z.discriminatedUnion('type', [ BuildingNode, ElevatorNode, LevelNode, + LeanToExtensionNode, ColumnNode, ConstructionDimensionNode, BlockNode, diff --git a/packages/core/src/store/actions/gutter-update.test.ts b/packages/core/src/store/actions/gutter-update.test.ts new file mode 100644 index 0000000000..13dc7ac49a --- /dev/null +++ b/packages/core/src/store/actions/gutter-update.test.ts @@ -0,0 +1,441 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { BuildingNode } from '../../schema/nodes/building' +import { + DownspoutNode, + type DownspoutNode as DownspoutNodeType, + isDefaultDownspoutNode, +} from '../../schema/nodes/downspout' +import { + GutterNode, + type GutterNode as GutterNodeType, + getDefaultGutterSide, +} from '../../schema/nodes/gutter' +import { LeanToExtensionNode } from '../../schema/nodes/lean-to-extension' +import { LevelNode } from '../../schema/nodes/level' +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 ??= + () => {} + +function setRoofScene(...segments: RoofSegmentNode[]) { + const roof = RoofNode.parse({ + id: 'roof_test' as never, + children: segments.map((segment) => segment.id), + }) + useScene + .getState() + .setScene( + Object.fromEntries([ + [roof.id, roof as AnyNode], + ...segments.map( + (segment) => [segment.id, { ...segment, parentId: roof.id } as AnyNode] as const, + ), + ]) as Record, + [roof.id as AnyNodeId], + ) +} + +function generatedGutters(segment: RoofSegmentNode): GutterNodeType[] { + return (segment.children ?? []) + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter( + (node): node is GutterNodeType => node?.type === 'gutter' && !!getDefaultGutterSide(node), + ) +} + +function generatedDownspouts(segment: RoofSegmentNode): DownspoutNodeType[] { + return (segment.children ?? []) + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter((node): node is DownspoutNodeType => isDefaultDownspoutNode(node)) +} + +describe('roof segment default gutters', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + readOnly: false, + }) + }) + + test('creates the roof-type gutters and automatic downspouts when auto mode is enabled', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + roofType: 'gable', + width: 8, + depth: 6, + }) + setRoofScene(segment) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + metadata: { autoGutter: true }, + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect(generatedGutters(nextSegment).map((gutter) => getDefaultGutterSide(gutter))).toEqual([ + '+Z', + '-Z', + ]) + const downspouts = generatedDownspouts(nextSegment) + expect(downspouts).toHaveLength(2) + for (const downspout of downspouts) { + const gutter = useScene.getState().nodes[downspout.gutterId as AnyNodeId] as GutterNodeType + expect(gutter.outlets.find((outlet) => outlet.id === downspout.outletId)).toMatchObject({ + generatedBy: 'default-downspout', + }) + } + }) + + test('adds multiple automatic downspouts to gutters that exceed the maximum run', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_long' as never, + roofType: 'gable', + width: 24, + depth: 6, + }) + setRoofScene(segment) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + metadata: { autoGutter: true }, + } as Partial, + ) + + const current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect(generatedDownspouts(current)).toHaveLength(6) + }) + + test('extends automatic downspouts from an upper floor to ground level', () => { + const lower = LevelNode.parse({ + id: 'level_lower' as never, + level: 0, + height: 3, + parentId: 'building_test', + }) + const upper = LevelNode.parse({ + id: 'level_upper' as never, + level: 1, + height: 3, + parentId: 'building_test', + children: ['roof_test'], + }) + const building = BuildingNode.parse({ + id: 'building_test' as never, + children: [lower.id, upper.id], + }) + const roof = RoofNode.parse({ + id: 'roof_test' as never, + parentId: upper.id, + children: ['rseg_test'], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 6, + }) + useScene + .getState() + .setScene( + Object.fromEntries( + [building, lower, upper, roof, segment].map((node) => [node.id, node as AnyNode]), + ) as Record, + [building.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + metadata: { autoGutter: true }, + } as Partial, + ) + + const current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + for (const downspout of generatedDownspouts(current)) { + expect(downspout.length).toBeCloseTo(3.158270110646816) + } + }) + + test('preserves generated gutter ids, settings, outlets, and downspout links on resize', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + roofType: 'gable', + metadata: { autoGutter: true }, + }) + setRoofScene(segment) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 8 } as Partial) + + let currentSegment = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + const front = generatedGutters(currentSegment).find( + (gutter) => getDefaultGutterSide(gutter) === '+Z', + )! + const outlet = { id: 'outlet_test', offset: 1, diameter: 0.08 } + useScene.getState().updateNode( + front.id as AnyNodeId, + { + profile: 'half-round', + outlets: [outlet], + } as Partial, + ) + const downspout = DownspoutNode.parse({ + id: 'downspout_test' as never, + gutterId: front.id, + outletId: outlet.id, + }) + useScene.getState().createNode(downspout, segment.id as AnyNodeId) + + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 12 } as Partial) + + currentSegment = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + const resizedFront = generatedGutters(currentSegment).find( + (gutter) => getDefaultGutterSide(gutter) === '+Z', + )! + expect(resizedFront).toMatchObject({ + id: front.id, + profile: 'half-round', + }) + expect(resizedFront.outlets).toContainEqual(outlet) + expect(resizedFront.length).toBeGreaterThan(front.length) + expect(useScene.getState().nodes[downspout.id as AnyNodeId]).toMatchObject({ + gutterId: front.id, + outletId: outlet.id, + }) + }) + + test('refreshes sibling gutters when an intersecting segment moves', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_main' as never, + parentId: 'roof_test' as never, + roofType: 'gable', + width: 8, + depth: 6, + overhang: 0.3, + metadata: { autoGutter: true }, + }) + const sibling = RoofSegmentNode.parse({ + id: 'rseg_cross' as never, + parentId: 'roof_test' as never, + roofType: 'gable', + width: 6, + depth: 4, + overhang: 0.3, + position: [0, 0, 8], + rotation: Math.PI / 2, + }) + setRoofScene(segment, sibling) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 8 } as Partial) + + let current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect( + generatedGutters(current).filter((gutter) => getDefaultGutterSide(gutter) === '+Z'), + ).toHaveLength(1) + + useScene.getState().updateNode( + sibling.id as AnyNodeId, + { + position: [0, 0, 3.26], + } as Partial, + ) + + current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + const front = generatedGutters(current).filter( + (gutter) => getDefaultGutterSide(gutter) === '+Z', + ) + expect(front).toHaveLength(2) + expect(front[0]?.length).toBeCloseTo(2) + expect(front[1]?.length).toBeCloseTo(2) + }) + + test('refreshes existing gutters when a sibling segment is added and removed', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_main' as never, + roofType: 'gable', + width: 8, + depth: 6, + overhang: 0.3, + metadata: { autoGutter: true }, + }) + setRoofScene(segment) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 8 } as Partial) + + const sibling = RoofSegmentNode.parse({ + id: 'rseg_cross' as never, + roofType: 'gable', + width: 6, + depth: 4, + overhang: 0.3, + position: [0, 0, 3.26], + rotation: Math.PI / 2, + }) + useScene.getState().createNode(sibling, 'roof_test' as AnyNodeId) + + let current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect( + generatedGutters(current).filter((gutter) => getDefaultGutterSide(gutter) === '+Z'), + ).toHaveLength(2) + + useScene.getState().deleteNode(sibling.id as AnyNodeId) + + current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect( + generatedGutters(current).filter((gutter) => getDefaultGutterSide(gutter) === '+Z'), + ).toHaveLength(1) + }) + + test('removes host drainage while an auto-connected lean-to occupies the eave', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_main' as never, + roofType: 'shed', + width: 8, + depth: 6, + metadata: { autoGutter: true }, + }) + setRoofScene(segment) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 8 } as Partial) + + let current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect(generatedGutters(current)).toHaveLength(1) + expect(generatedDownspouts(current)).toHaveLength(1) + + const leanTo = LeanToExtensionNode.parse({ + id: 'leanto_attached' as never, + autoSpan: true, + connectionMode: 'auto', + hostRoofId: 'roof_test', + hostRoofSegmentId: segment.id, + hostRoofEdge: '+Z', + }) + useScene.getState().createNode(leanTo) + + current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect(generatedGutters(current)).toHaveLength(0) + expect(generatedDownspouts(current)).toHaveLength(0) + + useScene.getState().deleteNode(leanTo.id as AnyNodeId) + + current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect(generatedGutters(current)).toHaveLength(1) + expect(generatedDownspouts(current)).toHaveLength(1) + }) + + test('splits and restores host drainage as a partial lean-to attachment changes', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_main' as never, + roofType: 'shed', + width: 8, + depth: 6, + metadata: { autoGutter: true }, + }) + setRoofScene(segment) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 8 } as Partial) + + const leanTo = LeanToExtensionNode.parse({ + id: 'leanto_partial' as never, + autoSpan: false, + connectionMode: 'auto', + hostRoofId: 'roof_test', + hostRoofSegmentId: segment.id, + hostRoofEdge: '+Z', + hostRoofEdgeRange: [0.25, 0.75], + }) + useScene.getState().createNode(leanTo) + + let current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect(generatedGutters(current)).toHaveLength(2) + expect(generatedDownspouts(current)).toHaveLength(2) + + useScene.getState().updateNode( + leanTo.id as AnyNodeId, + { + connectionMode: 'manual', + } as Partial, + ) + + current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect(generatedGutters(current)).toHaveLength(1) + expect(generatedDownspouts(current)).toHaveLength(1) + }) + + test('removes obsolete generated gutters and their linked downspouts on a roof-type change', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + roofType: 'gable', + metadata: { autoGutter: true }, + }) + setRoofScene(segment) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 8 } as Partial) + + let currentSegment = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + const back = generatedGutters(currentSegment).find( + (gutter) => getDefaultGutterSide(gutter) === '-Z', + )! + const downspout = DownspoutNode.parse({ + id: 'downspout_test' as never, + gutterId: back.id, + }) + useScene.getState().createNode(downspout, segment.id as AnyNodeId) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + roofType: 'shed', + } as Partial, + ) + + currentSegment = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect(generatedGutters(currentSegment).map((gutter) => getDefaultGutterSide(gutter))).toEqual([ + '+Z', + ]) + expect(useScene.getState().nodes[back.id as AnyNodeId]).toBeUndefined() + expect(useScene.getState().nodes[downspout.id as AnyNodeId]).toBeUndefined() + }) + + test('disabling auto mode removes generated drainage but keeps manual gutters', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + roofType: 'flat', + metadata: { autoGutter: true }, + }) + const manual = GutterNode.parse({ + id: 'gutter_manual' as never, + parentId: segment.id, + roofSegmentId: segment.id, + length: 1.5, + }) + setRoofScene({ ...segment, children: [manual.id] }) + useScene.setState((state) => ({ nodes: { ...state.nodes, [manual.id]: manual as AnyNode } })) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 8 } as Partial) + + const current = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + metadata: { ...current.metadata, autoGutter: false }, + } as Partial, + ) + + const disabledSegment = useScene.getState().nodes[segment.id as AnyNodeId] as RoofSegmentNode + expect(generatedGutters(disabledSegment)).toHaveLength(0) + expect(generatedDownspouts(disabledSegment)).toHaveLength(0) + expect(disabledSegment.children).toContain(manual.id) + expect(useScene.getState().nodes[manual.id as AnyNodeId]).toMatchObject({ length: 1.5 }) + }) +}) diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 07735e542f..a3e0838a03 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -3,12 +3,26 @@ import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema, + createDefaultGuttersForSegment, createDefaultRidgeVentsForSegment, + type DownspoutNode, + DownspoutNode as DownspoutNodeSchema, + defaultDownspoutMetadata, + type GutterEaveSide, + type GutterEdgeExclusion, + type GutterNode, + generateId, + getDefaultGutterSide, getEffectiveWallSurfaceMaterial, getWallSurfaceMaterialSignature, + isAutoGutterEnabled, isAutoRidgeVentEnabled, + isDefaultDownspoutNode, + isDefaultGutterNode, isDefaultRidgeVentNode, + planAutomaticDownspouts, type RoofSegmentNode, + resolveAutomaticDownspoutLength, type WallNode, } from '../../schema' import type { CollectionId } from '../../schema/collections' @@ -44,6 +58,19 @@ const DEFAULT_RIDGE_VENT_REFRESH_FIELDS = new Set([ 'dutchGabletRake', ]) +const DEFAULT_GUTTER_REFRESH_FIELDS = new Set([ + 'metadata', + 'position', + 'rotation', + 'roofType', + 'width', + 'depth', + 'wallHeight', + 'pitch', + 'overhang', + 'trim', +]) + type ZodCheckLike = { _zod?: { def?: { @@ -567,6 +594,378 @@ function refreshDefaultRidgeVentsForSegment( return nextVents.map((vent) => vent.id as AnyNodeId) } +function shouldRefreshDefaultGutters(data: Partial) { + return Object.keys(data).some((key) => DEFAULT_GUTTER_REFRESH_FIELDS.has(key)) +} + +function getLeanToGutterExclusions( + nodes: Record, + segmentId: RoofSegmentNode['id'], +): GutterEdgeExclusion[] { + return Object.values(nodes).flatMap((node) => { + if ( + node.type !== 'lean-to-extension' || + node.connectionMode !== 'auto' || + node.hostRoofSegmentId !== segmentId || + !node.hostRoofEdge + ) { + return [] + } + const range = node.hostRoofEdgeRange ?? [0, 1] + return [{ side: node.hostRoofEdge, from: range[0], to: range[1] }] + }) +} + +function addLeanToHostRoofId( + node: AnyNode | undefined, + nodes: Record, + roofIds: Set, +) { + if (node?.type !== 'lean-to-extension' || !node.hostRoofSegmentId) return + const segment = nodes[node.hostRoofSegmentId as AnyNodeId] + const roofId = + segment?.type === 'roof-segment' + ? (segment.parentId as AnyNodeId | null) + : (node.hostRoofId as AnyNodeId | undefined) + if (roofId && nodes[roofId]?.type === 'roof') roofIds.add(roofId) +} + +type DefaultGutterRefreshResult = { + dirtyIds: AnyNodeId[] + deletedIds: AnyNodeId[] +} + +// When an eave carries several default gutter runs on the same side (e.g. a run +// split by a lean-to exclusion), reuse the existing node whose plan position is +// closest to the desired run rather than an arbitrary queue order — otherwise +// the runs swap positions and their downspouts follow the wrong segment. +function takeNearestGutterId( + candidateIds: AnyNodeId[], + desired: GutterNode, + nodes: Record, +): AnyNodeId | undefined { + if (candidateIds.length === 0) return undefined + let bestIndex = 0 + let bestDistance = Number.POSITIVE_INFINITY + for (let i = 0; i < candidateIds.length; i++) { + const node = nodes[candidateIds[i]!] + const position = + node && 'position' in node ? (node.position as number[] | undefined) : undefined + const dx = (position?.[0] ?? 0) - desired.position[0] + const dz = (position?.[2] ?? 0) - desired.position[2] + const distance = dx * dx + dz * dz + if (distance < bestDistance) { + bestDistance = distance + bestIndex = i + } + } + return candidateIds.splice(bestIndex, 1)[0] +} + +function refreshDefaultGuttersForSegment( + nextNodes: Record, + segment: RoofSegmentNode, + roofSegments: readonly RoofSegmentNode[], +): DefaultGutterRefreshResult { + const childIds = Array.isArray(segment.children) ? (segment.children as AnyNodeId[]) : [] + const existingIds = childIds.filter((childId) => + isDefaultGutterNode(nextNodes[childId], segment.id), + ) + if (!isAutoGutterEnabled(segment, nextNodes) && existingIds.length === 0) { + return { dirtyIds: [], deletedIds: [] } + } + + const existingBySide = new Map() + for (const id of existingIds) { + const side = getDefaultGutterSide(nextNodes[id], segment.id) + if (!side) continue + const ids = existingBySide.get(side) ?? [] + ids.push(id) + existingBySide.set(side, ids) + } + + const desiredGutters = isAutoGutterEnabled(segment, nextNodes) + ? createDefaultGuttersForSegment( + segment, + roofSegments, + getLeanToGutterExclusions(nextNodes, segment.id), + ) + : [] + const desiredChildIds: AnyNodeId[] = [] + const dirtyIds: AnyNodeId[] = [] + + for (const desired of desiredGutters) { + const side = getDefaultGutterSide(desired, segment.id) + if (!side) continue + const matchingIds = existingBySide.get(side) + const existingId = matchingIds + ? takeNearestGutterId(matchingIds, desired, nextNodes) + : undefined + + if (existingId) { + const existing = nextNodes[existingId] as GutterNode + nextNodes[existingId] = { + ...existing, + parentId: segment.id, + roofSegmentId: segment.id, + position: desired.position, + rotation: desired.rotation, + length: desired.length, + } as AnyNode + desiredChildIds.push(existingId) + dirtyIds.push(existingId) + continue + } + + const desiredId = desired.id as AnyNodeId + nextNodes[desiredId] = { ...desired, parentId: segment.id } as AnyNode + desiredChildIds.push(desiredId) + dirtyIds.push(desiredId) + } + + const retainedIdSet = new Set(desiredChildIds) + const deletedIds = existingIds.filter((id) => !retainedIdSet.has(id)) + const deletedGutterIds = new Set(deletedIds) + for (const [nodeId, node] of Object.entries(nextNodes) as [AnyNodeId, AnyNode][]) { + if ( + node.type === 'downspout' && + node.gutterId && + deletedGutterIds.has(node.gutterId as AnyNodeId) + ) { + deletedIds.push(nodeId) + } + } + + const deletedIdSet = new Set(deletedIds) + for (const id of deletedIds) delete nextNodes[id] + + nextNodes[segment.id as AnyNodeId] = { + ...segment, + children: [ + ...childIds.filter((childId) => !deletedIdSet.has(childId) && !existingIds.includes(childId)), + ...desiredChildIds, + ], + } as AnyNode + + return { dirtyIds, deletedIds } +} + +function getRoofSegments( + nextNodes: Record, + segment: RoofSegmentNode, +): RoofSegmentNode[] { + const roof = segment.parentId ? nextNodes[segment.parentId as AnyNodeId] : undefined + if (!(roof && roof.type === 'roof')) return [segment] + return (roof.children ?? []) + .map((childId) => nextNodes[childId as AnyNodeId]) + .filter((node): node is RoofSegmentNode => node?.type === 'roof-segment') +} + +function refreshDefaultDownspoutsForRoof( + nextNodes: Record, + roofSegments: readonly RoofSegmentNode[], +): DefaultGutterRefreshResult { + const gutters = roofSegments.flatMap((segment) => { + const current = nextNodes[segment.id as AnyNodeId] + if (current?.type !== 'roof-segment') return [] + return (current.children ?? []) + .map((childId) => nextNodes[childId as AnyNodeId]) + .filter( + (node): node is GutterNode => + node?.type === 'gutter' && isDefaultGutterNode(node, current.id), + ) + }) + const gutterById = new Map(gutters.map((gutter) => [gutter.id, gutter])) + const downspouts = Object.values(nextNodes).filter( + (node): node is DownspoutNode => + node?.type === 'downspout' && Boolean(node.gutterId && gutterById.has(node.gutterId)), + ) + const generated = downspouts.filter((downspout) => { + if (!isDefaultDownspoutNode(downspout)) return false + const gutter = downspout.gutterId ? gutterById.get(downspout.gutterId) : undefined + return gutter?.outlets.some( + (outlet) => outlet.id === downspout.outletId && outlet.generatedBy === 'default-downspout', + ) + }) + const generatedIds = new Set(generated.map((downspout) => downspout.id)) + const manual = downspouts.filter((downspout) => !generatedIds.has(downspout.id)) + const placements = planAutomaticDownspouts({ + segments: roofSegments, + gutters, + downspouts: manual, + }) + const segmentById = new Map( + roofSegments.map((segment) => [segment.id, segment]), + ) + const availableByGutter = new Map() + for (const downspout of generated) { + if (!downspout.gutterId) continue + const available = availableByGutter.get(downspout.gutterId) ?? [] + available.push(downspout) + availableByGutter.set(downspout.gutterId, available) + } + + const retainedIds = new Set() + const retainedOutletIds = new Set() + const dirtyIds = new Set() + const deletedIds: AnyNodeId[] = [] + const outletsByGutter = new Map(gutters.map((gutter) => [gutter.id, [...(gutter.outlets ?? [])]])) + + for (const placement of placements) { + const gutter = gutterById.get(placement.gutterId) + if (!gutter?.roofSegmentId) continue + const segment = segmentById.get(gutter.roofSegmentId) + if (!segment) continue + const outlets = outletsByGutter.get(gutter.id) ?? [] + const available = availableByGutter.get(gutter.id) ?? [] + let bestIndex = -1 + let bestDistance = Number.POSITIVE_INFINITY + for (let index = 0; index < available.length; index++) { + const candidate = available[index]! + const outlet = outlets.find((entry) => entry.id === candidate.outletId) + const distance = outlet ? Math.abs(outlet.offset - placement.offset) : 0 + if (distance < bestDistance) { + bestDistance = distance + bestIndex = index + } + } + + const existing = bestIndex >= 0 ? available.splice(bestIndex, 1)[0] : undefined + const outletId = existing?.outletId ?? generateId('outlet') + const outletIndex = outlets.findIndex((outlet) => outlet.id === outletId) + const outlet = { + id: outletId, + offset: placement.offset, + diameter: 0.07, + generatedBy: 'default-downspout' as const, + } + if (outletIndex >= 0) outlets[outletIndex] = { ...outlets[outletIndex]!, ...outlet } + else outlets.push(outlet) + outletsByGutter.set(gutter.id, outlets) + retainedOutletIds.add(outletId) + + const length = resolveAutomaticDownspoutLength(nextNodes, segment, gutter, placement.offset) + const downspout = existing + ? ({ + ...existing, + parentId: segment.id, + gutterId: gutter.id, + outletId, + length: existing.lengthMode === 'manual' ? existing.length : length, + lengthMode: existing.lengthMode === 'manual' ? 'manual' : 'to-ground', + } as DownspoutNode) + : DownspoutNodeSchema.parse({ + name: 'Downspout', + parentId: segment.id, + gutterId: gutter.id, + outletId, + length, + lengthMode: 'to-ground', + diameter: outlet.diameter, + metadata: defaultDownspoutMetadata(), + }) + nextNodes[downspout.id as AnyNodeId] = downspout as AnyNode + retainedIds.add(downspout.id as AnyNodeId) + dirtyIds.add(downspout.id as AnyNodeId) + + const currentSegment = nextNodes[segment.id as AnyNodeId] + if (currentSegment?.type === 'roof-segment') { + nextNodes[segment.id as AnyNodeId] = { + ...currentSegment, + children: Array.from(new Set([...(currentSegment.children ?? []), downspout.id])), + } as AnyNode + dirtyIds.add(segment.id as AnyNodeId) + } + } + + for (const [gutterId, outlets] of outletsByGutter) { + const gutter = nextNodes[gutterId as AnyNodeId] + if (gutter?.type !== 'gutter') continue + nextNodes[gutterId as AnyNodeId] = { + ...gutter, + outlets: outlets.filter( + (outlet) => outlet.generatedBy !== 'default-downspout' || retainedOutletIds.has(outlet.id), + ), + } as AnyNode + dirtyIds.add(gutterId as AnyNodeId) + } + + for (const downspout of generated) { + const downspoutId = downspout.id as AnyNodeId + if (retainedIds.has(downspoutId)) continue + const gutter = downspout.gutterId ? nextNodes[downspout.gutterId as AnyNodeId] : undefined + if (gutter?.type === 'gutter' && downspout.outletId) { + nextNodes[gutter.id as AnyNodeId] = { + ...gutter, + outlets: (gutter.outlets ?? []).filter((outlet) => outlet.id !== downspout.outletId), + } as AnyNode + dirtyIds.add(gutter.id as AnyNodeId) + } + const parent = downspout.parentId ? nextNodes[downspout.parentId as AnyNodeId] : undefined + if (parent?.type === 'roof-segment') { + nextNodes[parent.id as AnyNodeId] = { + ...parent, + children: (parent.children ?? []).filter((childId) => childId !== downspout.id), + } as AnyNode + dirtyIds.add(parent.id as AnyNodeId) + } + delete nextNodes[downspoutId] + deletedIds.push(downspoutId) + } + + return { dirtyIds: [...dirtyIds], deletedIds } +} + +function refreshDefaultGuttersForRoof( + nextNodes: Record, + segment: RoofSegmentNode, +): DefaultGutterRefreshResult { + const roofSegments = getRoofSegments(nextNodes, segment) + const dirtyIds: AnyNodeId[] = [] + const deletedIds: AnyNodeId[] = [] + for (const roofSegment of roofSegments) { + const current = nextNodes[roofSegment.id as AnyNodeId] + if (current?.type !== 'roof-segment') continue + const result = refreshDefaultGuttersForSegment(nextNodes, current, roofSegments) + dirtyIds.push(...result.dirtyIds) + deletedIds.push(...result.deletedIds) + } + const downspoutResult = refreshDefaultDownspoutsForRoof(nextNodes, roofSegments) + dirtyIds.push(...downspoutResult.dirtyIds) + deletedIds.push(...downspoutResult.deletedIds) + return { dirtyIds, deletedIds } +} + +function collectDefaultGutterRefresh( + result: DefaultGutterRefreshResult, + dirtyIds: Set, + deletedIds: Set, +) { + for (const id of result.dirtyIds) dirtyIds.add(id) + for (const id of result.deletedIds) deletedIds.add(id) +} + +function refreshDefaultGuttersForRoofIds( + nextNodes: Record, + roofIds: Iterable, + dirtyIds: Set, + deletedIds: Set, +) { + for (const roofId of new Set(roofIds)) { + const roof = nextNodes[roofId] + if (roof?.type !== 'roof') continue + const segment = (roof.children ?? []) + .map((childId) => nextNodes[childId as AnyNodeId]) + .find((child): child is RoofSegmentNode => child?.type === 'roof-segment') + if (!segment) continue + collectDefaultGutterRefresh( + refreshDefaultGuttersForRoof(nextNodes, segment), + dirtyIds, + deletedIds, + ) + } +} + // Track pending RAF for updateNodesAction to prevent multiple queued callbacks let pendingRafId: number | null = null let pendingUpdates: Set = new Set() @@ -786,6 +1185,8 @@ const createNodesActionImpl = ( ops: NodeCreateOp[], ) => { if (get().readOnly) return + const extraNodesToMarkDirty = new Set() + const extraNodesToClearDirty = new Set() set((state) => { const nextNodes = { ...state.nodes } const nextRootIds = [...state.rootNodeIds] @@ -824,6 +1225,23 @@ const createNodesActionImpl = ( } } + const refreshedRoofIds = new Set() + for (const { node } of ops) { + const created = nextNodes[node.id as AnyNodeId] + if (created?.type === 'roof-segment' && created.parentId) { + refreshedRoofIds.add(created.parentId as AnyNodeId) + } + addLeanToHostRoofId(created, nextNodes, refreshedRoofIds) + } + refreshDefaultGuttersForRoofIds( + nextNodes, + refreshedRoofIds, + extraNodesToMarkDirty, + extraNodesToClearDirty, + ) + + addActiveSceneCommitNodeIds([...extraNodesToMarkDirty, ...extraNodesToClearDirty]) + return { nodes: nextNodes, rootNodeIds: nextRootIds } }) @@ -833,6 +1251,8 @@ const createNodesActionImpl = ( if (parentId) get().markDirty(parentId) else if (node.parentId) get().markDirty(node.parentId as AnyNodeId) }) + for (const id of extraNodesToMarkDirty) get().markDirty(id) + for (const id of extraNodesToClearDirty) get().clearDirty(id) } const applyNodeChangesActionImpl = ( @@ -846,6 +1266,7 @@ const applyNodeChangesActionImpl = ( const updateOps = changes.update ?? [] const deleteOps = changes.delete ?? [] const nodesToMarkDirty = new Set() + const nodesToClearDirty = new Set() const parentsToMarkDirty = new Set() set((state) => { @@ -853,11 +1274,14 @@ const applyNodeChangesActionImpl = ( const nextCollections = { ...state.collections } const nextRootIds = [...state.rootNodeIds] let resolvedRootIds = nextRootIds + const roofsToRefresh = new Set() for (const { id, data } of updateOps) { const currentNode = nextNodes[id] if (!currentNode) continue + addLeanToHostRoofId(currentNode, nextNodes, roofsToRefresh) const updatedNode = parseUpdatedNode(currentNode, data) + addLeanToHostRoofId(updatedNode, nextNodes, roofsToRefresh) if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { const oldParentId = currentNode.parentId as AnyNodeId | null @@ -887,6 +1311,10 @@ const applyNodeChangesActionImpl = ( nodesToMarkDirty.add(ventId) } } + const currentSegment = nextNodes[id] + if (currentSegment?.type === 'roof-segment' && shouldRefreshDefaultGutters(data)) { + if (currentSegment.parentId) roofsToRefresh.add(currentSegment.parentId as AnyNodeId) + } nodesToMarkDirty.add(id) } @@ -896,6 +1324,10 @@ const applyNodeChangesActionImpl = ( nextNodes[newNode.id as AnyNodeId] = newNode nodesToMarkDirty.add(newNode.id as AnyNodeId) + if (newNode.type === 'roof-segment' && effectiveParentId) { + roofsToRefresh.add(effectiveParentId) + } + addLeanToHostRoofId(newNode, nextNodes, roofsToRefresh) if (effectiveParentId && nextNodes[effectiveParentId]) { const parent = nextNodes[effectiveParentId] @@ -927,6 +1359,14 @@ const applyNodeChangesActionImpl = ( collectDelete(id) } + for (const id of allIdsToDelete) { + const node = nextNodes[id] + addLeanToHostRoofId(node, nextNodes, roofsToRefresh) + if (node?.type === 'roof-segment' && node.parentId) { + roofsToRefresh.add(node.parentId as AnyNodeId) + } + } + for (const id of allIdsToDelete) { const node = nextNodes[id] if (!node) continue @@ -960,7 +1400,14 @@ const applyNodeChangesActionImpl = ( delete nextNodes[id] } - addActiveSceneCommitNodeIds([...allIdsToDelete, ...nodesToMarkDirty, ...parentsToMarkDirty]) + refreshDefaultGuttersForRoofIds(nextNodes, roofsToRefresh, nodesToMarkDirty, nodesToClearDirty) + + addActiveSceneCommitNodeIds([ + ...allIdsToDelete, + ...nodesToMarkDirty, + ...nodesToClearDirty, + ...parentsToMarkDirty, + ]) return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections } }) @@ -968,6 +1415,9 @@ const applyNodeChangesActionImpl = ( for (const id of nodesToMarkDirty) { get().markDirty(id) } + for (const id of nodesToClearDirty) { + get().clearDirty(id) + } for (const id of parentsToMarkDirty) { get().markDirty(id) const parent = get().nodes[id] @@ -987,6 +1437,8 @@ const updateNodesActionImpl = ( if (get().readOnly) return const parentsToUpdate = new Set() const extraNodesToUpdate = new Set() + const extraNodesToDelete = new Set() + const roofsToRefresh = new Set() set((state) => { const nextNodes = { ...state.nodes } @@ -994,7 +1446,9 @@ const updateNodesActionImpl = ( for (const { id, data } of updates) { const currentNode = nextNodes[id] if (!currentNode) continue + addLeanToHostRoofId(currentNode, nextNodes, roofsToRefresh) const updatedNode = parseUpdatedNode(currentNode, data) + addLeanToHostRoofId(updatedNode, nextNodes, roofsToRefresh) // Handle Reparenting Logic if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { @@ -1039,12 +1493,24 @@ const updateNodesActionImpl = ( extraNodesToUpdate.add(ventId) } } + const currentSegment = nextNodes[id] + if (currentSegment?.type === 'roof-segment' && shouldRefreshDefaultGutters(data)) { + if (currentSegment.parentId) roofsToRefresh.add(currentSegment.parentId as AnyNodeId) + } } + refreshDefaultGuttersForRoofIds( + nextNodes, + roofsToRefresh, + extraNodesToUpdate, + extraNodesToDelete, + ) + addActiveSceneCommitNodeIds([ ...updates.map(({ id }) => id), ...parentsToUpdate, ...extraNodesToUpdate, + ...extraNodesToDelete, ]) return { nodes: nextNodes } @@ -1064,6 +1530,9 @@ const updateNodesActionImpl = ( for (const id of extraNodesToUpdate) { pendingUpdates.add(id) } + for (const id of extraNodesToDelete) { + get().clearDirty(id) + } if (pendingRafId !== null) { cancelAnimationFrame(pendingRafId) @@ -1115,6 +1584,14 @@ const deleteNodesActionImpl = ( for (const plan of mergePlans) { allIds.add(plan.secondaryWallId) } + const affectedRoofIds = new Set() + for (const id of allIds) { + const node = nextNodes[id] + addLeanToHostRoofId(node, nextNodes, affectedRoofIds) + if (node?.type === 'roof-segment' && node.parentId) { + affectedRoofIds.add(node.parentId as AnyNodeId) + } + } for (const id of allIds) deletedIds.add(id) // Let each deleted kind undo what it imposed on its neighbours (e.g. an @@ -1217,7 +1694,9 @@ const deleteNodesActionImpl = ( delete nextNodes[id] } - addActiveSceneCommitNodeIds([...allIds, ...parentsToMarkDirty, ...nodesToMarkDirty]) + refreshDefaultGuttersForRoofIds(nextNodes, affectedRoofIds, nodesToMarkDirty, deletedIds) + + addActiveSceneCommitNodeIds([...deletedIds, ...parentsToMarkDirty, ...nodesToMarkDirty]) return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections } }) diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index c7e30961a4..5ba5f46d58 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -235,3 +235,45 @@ describe('supportSlabId remap', () => { expect((clonedExternal as { supportSlabId?: string }).supportSlabId).toBe('slab_external') }) }) + +describe('lean-to roof attachment remap', () => { + test('remaps both host roof references in whole-scene and level clones', () => { + const level = makeNode('level_1', 'level', { + children: ['roof_1', 'leanto_1'], + }) + const roof = makeNode('roof_1', 'roof', { + parentId: 'level_1', + children: ['roofseg_1'], + }) + const segment = makeNode('roofseg_1', 'roof-segment', { + parentId: 'roof_1', + }) + const leanTo = makeNode('leanto_1', 'lean-to-extension', { + parentId: 'level_1', + hostRoofId: 'roof_1', + hostRoofSegmentId: 'roofseg_1', + }) + const nodes = { + ['level_1' as AnyNodeId]: level, + ['roof_1' as AnyNodeId]: roof, + ['roofseg_1' as AnyNodeId]: segment, + ['leanto_1' as AnyNodeId]: leanTo, + } + + const whole = cloneSceneGraph({ nodes, rootNodeIds: ['level_1' as AnyNodeId] }) + const wholeRoof = Object.values(whole.nodes).find((node) => node.type === 'roof')! + const wholeSegment = Object.values(whole.nodes).find((node) => node.type === 'roof-segment')! + const wholeLeanTo = Object.values(whole.nodes).find( + (node) => node.type === 'lean-to-extension', + )! as unknown as { hostRoofId: string; hostRoofSegmentId: string } + expect(wholeLeanTo.hostRoofId).toBe(wholeRoof.id) + expect(wholeLeanTo.hostRoofSegmentId).toBe(wholeSegment.id) + + const levelClone = cloneLevelSubtree(nodes, 'level_1' as AnyNodeId) + const levelLeanTo = levelClone.clonedNodes.find( + (node) => node.type === 'lean-to-extension', + )! as unknown as { hostRoofId: string; hostRoofSegmentId: string } + expect(levelLeanTo.hostRoofId).toBe(levelClone.idMap.get('roof_1')) + expect(levelLeanTo.hostRoofSegmentId).toBe(levelClone.idMap.get('roofseg_1')) + }) +}) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 13e89ab260..d68227f070 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -91,6 +91,18 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { ) as string | undefined } + if ('hostRoofId' in clonedNode && typeof clonedNode.hostRoofId === 'string') { + ;(clonedNode as Record).hostRoofId = idMap.get(clonedNode.hostRoofId) as + | string + | undefined + } + + if ('hostRoofSegmentId' in clonedNode && typeof clonedNode.hostRoofSegmentId === 'string') { + ;(clonedNode as Record).hostRoofSegmentId = idMap.get( + clonedNode.hostRoofSegmentId, + ) as string | undefined + } + // Remap supportSlabId (persisted slab-support hosts). The 'ground' // sentinel is not a node id — keep it as-is. if ( @@ -272,6 +284,16 @@ export function cloneLevelSubtree( idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId } + if ('hostRoofId' in cloned && typeof cloned.hostRoofId === 'string') { + ;(cloned as Record).hostRoofId = + idMap.get(cloned.hostRoofId) ?? cloned.hostRoofId + } + + if ('hostRoofSegmentId' in cloned && typeof cloned.hostRoofSegmentId === 'string') { + ;(cloned as Record).hostRoofSegmentId = + idMap.get(cloned.hostRoofSegmentId) ?? cloned.hostRoofSegmentId + } + // Remap supportSlabId when the host slab is inside the cloned subtree; // preserve it otherwise (like wallId, the reference may point outside). if ('supportSlabId' in cloned && typeof cloned.supportSlabId === 'string') { 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 770f7d4629..d66048b2ce 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 @@ -6,6 +6,7 @@ import { type AnyNodeId, bboxAnchors, bboxCornerAnchors, + createSceneApi, emitter, type FloorplanMoveTargetSession, nodeRegistry, @@ -104,12 +105,14 @@ export function FloorplanRegistryMoveOverlay() { // ── Path 1 — kind-owned `floorplanMoveTarget` ─────────────────── if (hasMoveTarget && def?.floorplanMoveTarget) { const sceneNodes = useScene.getState().nodes + const sceneApi = createSceneApi(useScene) const session: FloorplanMoveTargetSession = ( def.floorplanMoveTarget as (a: { node: AnyNode nodes: Record + sceneApi: ReturnType }) => FloorplanMoveTargetSession - )({ node: movingNode, nodes: sceneNodes }) + )({ node: movingNode, nodes: sceneNodes, sceneApi }) // Capture snapshots of every affected node BEFORE the first apply // so the single-undo dance has a clean baseline to revert to. 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 2af0ceb37b..75a64b6f02 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 @@ -1048,6 +1048,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { nodes: sceneNodes, initialPlanPoint, gridSnapStep: useEditor.getState().gridSnapStep, + sceneApi: createSceneApi(useScene), }) if (!(session.commit && session.canCommit())) return session.commit() @@ -1091,6 +1092,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { nodes: sceneNodes, initialPlanPoint, gridSnapStep: useEditor.getState().gridSnapStep, + sceneApi: createSceneApi(useScene), }) const snapshots: NodeSnapshot[] = [] diff --git a/packages/editor/src/components/editor/handles/resize-snap.test.ts b/packages/editor/src/components/editor/handles/resize-snap.test.ts index 2c9456022e..eb9abac4ba 100644 --- a/packages/editor/src/components/editor/handles/resize-snap.test.ts +++ b/packages/editor/src/components/editor/handles/resize-snap.test.ts @@ -49,4 +49,17 @@ describe('resolveResizeSnapValue', () => { ).toBe(0.56) expect(magneticSnap).not.toHaveBeenCalled() }) + + it('keeps the last valid value when pointer projection is non-finite', () => { + expect( + resolveResizeSnapValue({ + rawValue: Number.NaN, + fallbackValue: 12.3, + gridSnapEnabled: false, + gridSnapActive: false, + gridSnapStep: 0.5, + magneticSnapActive: false, + }), + ).toBe(12.3) + }) }) diff --git a/packages/editor/src/components/editor/handles/resize-snap.ts b/packages/editor/src/components/editor/handles/resize-snap.ts index 3bd4e60315..34c1d2f3eb 100644 --- a/packages/editor/src/components/editor/handles/resize-snap.ts +++ b/packages/editor/src/components/editor/handles/resize-snap.ts @@ -2,6 +2,7 @@ import { snapScalar } from '@pascal-app/core' export function resolveResizeSnapValue({ rawValue, + fallbackValue = rawValue, gridSnapEnabled, gridSnapActive, gridSnapStep, @@ -9,15 +10,18 @@ export function resolveResizeSnapValue({ magneticSnap, }: { rawValue: number + fallbackValue?: number gridSnapEnabled: boolean gridSnapActive: boolean gridSnapStep: number magneticSnapActive: boolean magneticSnap?: (value: number) => number }): number { + if (!Number.isFinite(rawValue)) return fallbackValue const gridValue = gridSnapEnabled && gridSnapActive && gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue - return magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue + const resolved = magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue + return Number.isFinite(resolved) ? resolved : fallbackValue } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 8c8b3bbbd6..168e7c6a1e 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -772,6 +772,7 @@ function LinearArrow({ const linearDescriptor = descriptor.kind === 'linear-resize' ? descriptor : null const snappedNext = resolveResizeSnapValue({ rawValue: rawNext, + fallbackValue: lastTickValue, gridSnapEnabled: linearDescriptor?.gridSnap === true, gridSnapActive: isGridSnapActive(), gridSnapStep: useEditor.getState().gridSnapStep, diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 852db3dfa4..c47a6a61e5 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,6 +1,6 @@ import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core' -import { nodeRegistry } from '@pascal-app/core' -import { Suspense } from 'react' +import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core' +import { Suspense, useMemo } from 'react' import { useMovingNode } from '../../../store/use-interaction-scope' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' @@ -28,6 +28,7 @@ export const MoveTool: React.FC<{ onSpawnMoved?: (nodeId: SpawnNode['id']) => void }> = ({ onNodeMoved }) => { const movingNode = useMovingNode() + const sceneApi = useMemo(() => createSceneApi(useScene), []) if (!movingNode) return null @@ -37,7 +38,7 @@ export const MoveTool: React.FC<{ if (RegistryMove) { return ( - + ) } diff --git a/packages/editor/src/components/ui/controls/segmented-control.tsx b/packages/editor/src/components/ui/controls/segmented-control.tsx index 5508eb06fd..773afeb872 100644 --- a/packages/editor/src/components/ui/controls/segmented-control.tsx +++ b/packages/editor/src/components/ui/controls/segmented-control.tsx @@ -7,6 +7,7 @@ interface SegmentedControlProps { onChange: (value: T) => void options: { label: React.ReactNode; value: T }[] className?: string + disabled?: boolean mixed?: boolean } @@ -15,12 +16,14 @@ export function SegmentedControl({ onChange, options, className, + disabled = false, mixed = false, }: SegmentedControlProps) { return (
@@ -33,7 +36,9 @@ export function SegmentedControl({ isSelected ? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50' : 'text-muted-foreground hover:bg-white/5 hover:text-foreground', + disabled && 'cursor-not-allowed hover:bg-transparent hover:text-muted-foreground', )} + disabled={disabled} key={option.value} onClick={() => onChange(option.value)} type="button" diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 93d5cd2973..3660c28bfe 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -235,6 +235,22 @@ export function HelperManager() { ) } + // A single-node resize arrow is still an active snapping interaction. Roof + // width/depth handles opt into grid snapping, so keep the mode and grid-step + // controls visible for the whole drag instead of falling through to idle + // selection hints. + if (activeHandleDrag) { + return ( + + ) + } + // Reshaping a node's geometry (endpoint / curve / polygon corner). Checked // before the select branch so the idle "drag selected / add objects" hints // never leak over an in-progress reshape — and it gets its own snapping chip. diff --git a/packages/editor/src/components/ui/panels/homogeneous-selection.ts b/packages/editor/src/components/ui/panels/homogeneous-selection.ts index 8f8f39baef..9f94e4db74 100644 --- a/packages/editor/src/components/ui/panels/homogeneous-selection.ts +++ b/packages/editor/src/components/ui/panels/homogeneous-selection.ts @@ -45,4 +45,3 @@ export function resolveHomogeneousSelection( } return first.type } - diff --git a/packages/editor/src/components/ui/panels/multi-field-value.ts b/packages/editor/src/components/ui/panels/multi-field-value.ts index 4ae4f61847..44c494c0c9 100644 --- a/packages/editor/src/components/ui/panels/multi-field-value.ts +++ b/packages/editor/src/components/ui/panels/multi-field-value.ts @@ -122,7 +122,7 @@ export function buildMultiNodePatches( if (Object.keys(patch).length === 0) continue if (parametrics?.derive) { const next = { ...node, ...patch } as AnyNode - patch = { ...patch, ...parametrics.derive(next, patch) } as Partial + patch = { ...patch, ...parametrics.derive(next, patch, node) } as Partial } updates.push({ id, data: patch }) if (parametrics?.reconcile) { diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index ad3ee103ec..903542cecf 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -65,7 +65,7 @@ export function ParametricInspector({ const node = scene.nodes[selectedId] if (parametrics?.derive && node) { const next = { ...node, ...patch } as AnyNode - patch = { ...patch, ...parametrics.derive(next, patch) } + patch = { ...patch, ...parametrics.derive(next, patch, node as AnyNode) } } // Bundle the edited node + any reconcile follow-ups into ONE // updateNodes call so a single inspector edit is a single undo step. diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts new file mode 100644 index 0000000000..53bc693329 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from 'bun:test' +import { getTreeNodeComponent } from './tree-node' + +describe('site tree node routing', () => { + test('renders plugin node kinds through the generic tree row', () => { + expect(getTreeNodeComponent('lean-to-extension')).toBe(getTreeNodeComponent('plugin-kind')) + }) +}) 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 6a037bcd6a..9140660b15 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 @@ -117,6 +117,12 @@ interface TreeNodeProps { isLast?: boolean } +type TreeNodeComponent = React.ComponentType<{ + depth: number + isLast?: boolean + nodeId: AnyNodeId +}> + // Per-kind tree-node components keyed by `node.type`. Lookup replaces // the legacy switch — adding a kind to this map is now the only edit // needed in this file (the switch's `case '':` clauses were @@ -124,10 +130,7 @@ interface TreeNodeProps { // outside the registry; future work moves these to a // `def.presentation`-driven generic tree-node and removes this map // entirely). -const treeNodeByType: Record< - string, - React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }> -> = { +const treeNodeByType: Record = { building: BuildingTreeNode as React.ComponentType<{ depth: number isLast?: boolean @@ -181,6 +184,10 @@ const treeNodeByType: Record< item: ItemTreeNode, } +export function getTreeNodeComponent(nodeType: string): TreeNodeComponent { + return treeNodeByType[nodeType] ?? RegistryTreeNode +} + export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { // Registry-driven row hiding (`def.tree.hidden`) — primitive boolean // selector so unrelated scene updates don't re-render every row. @@ -192,8 +199,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr const nodeType = useScene((state) => state.nodes[nodeId]?.type) if (shouldHide) return null if (!nodeType) return null - const Component = treeNodeByType[nodeType] - if (!Component) return null + const Component = getTreeNodeComponent(nodeType) return }) diff --git a/packages/editor/src/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts index e3cca8f27c..cb692c5249 100644 --- a/packages/editor/src/lib/selection-routing.test.ts +++ b/packages/editor/src/lib/selection-routing.test.ts @@ -287,6 +287,43 @@ describe('resolveCanvasSelectionNode', () => { }), ).toBe(proxyGroup) }) + + test('routes proxied lean-to roof children to the owning extension', () => { + const leanTo = { + id: 'lean_to_1', + type: 'lean-to-extension', + metadata: {}, + } as unknown as AnyNode + const roof = { + id: 'roof_lean_to', + type: 'roof', + parentId: leanTo.id, + metadata: { + managedByLeanTo: leanTo.id, + leanToRole: 'roof', + nodeSelectionProxyId: leanTo.id, + }, + } as unknown as AnyNode + const segment = { + id: 'rseg_lean_to', + type: 'roof-segment', + parentId: roof.id, + metadata: { + managedByLeanTo: leanTo.id, + leanToRole: 'roof-segment', + nodeSelectionProxyId: leanTo.id, + }, + } as unknown as AnyNode + + const nodes = { + [leanTo.id]: leanTo, + [roof.id]: roof, + [segment.id]: segment, + } + + expect(resolveCanvasSelectionNode({ node: roof, nodes, selectedIds: [] })).toBe(leanTo) + expect(resolveCanvasSelectionNode({ node: segment, nodes, selectedIds: [] })).toBe(leanTo) + }) }) describe('shouldPreserveSelectedRoofHostTarget', () => { diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 4ce3c158ab..340f02bd64 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -1858,7 +1858,8 @@ function BlockEditor({ .addScaledVector(worldAxis, parameter - initialParameter) const localPoint = target.worldToLocal(worldPoint) const distance = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) - const snapStep = !pointerEvent.altKey && isGridSnapActive() ? 0.1 : 0 + const snapStep = + !pointerEvent.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 const factor = blockScaleFactorFromDrag(distance, gizmoLength, snapStep) if (snapStep > 0 && Math.abs(factor - 1) > 1e-6 && factor !== lastSnapFactor) { lastSnapFactor = factor @@ -1953,7 +1954,7 @@ function BlockEditor({ const updatePreview = (clientX: number, clientY: number, altKey: boolean) => { const pointer = new Vector2(clientX, clientY) const distance = pointer.distanceTo(pivotClient) - initialDistance - const snapStep = !altKey && isGridSnapActive() ? 0.1 : 0 + const snapStep = !altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 const factor = blockScaleFactorFromDrag(distance, initialDistance, snapStep) if (snapStep > 0 && Math.abs(factor - 1) > 1e-6 && factor !== lastSnapFactor) { lastSnapFactor = factor diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index f085a7d2ed..fef3b1b0f2 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -220,7 +220,7 @@ export const boxVentDefinition: NodeDefinition = { presentation: { label: 'Box Vent', description: 'Small louvered exhaust vent that sits on a roof slope.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/box-vent.webp' }, paletteSection: 'structure', paletteOrder: 120, }, diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index 986e3a0060..a1bd20615e 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -410,7 +410,7 @@ export const chimneyDefinition: NodeDefinition = { presentation: { label: 'Chimney', description: 'Vertical masonry stack on a roof segment.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/chimney.webp' }, paletteSection: 'structure', paletteOrder: 122, }, diff --git a/packages/nodes/src/column/definition.test.ts b/packages/nodes/src/column/definition.test.ts new file mode 100644 index 0000000000..b47e2bf886 --- /dev/null +++ b/packages/nodes/src/column/definition.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import { ColumnNode } from '@pascal-app/core' +import { columnDefinition } from './definition' + +describe('column definition', () => { + test('keeps only user-owned handles for lean-to managed columns', () => { + const column = ColumnNode.parse({ + metadata: { + managedByLeanTo: 'lean_to_1', + leanToRole: 'post', + }, + }) + const handles = + typeof columnDefinition.handles === 'function' + ? columnDefinition.handles(column) + : columnDefinition.handles + + expect(handles.map((handle) => handle.kind)).toEqual(['arc-resize']) + }) + + test('keeps brace and rotation handles for lean-to managed K-brace columns', () => { + const column = ColumnNode.parse({ + supportStyle: 'k-brace', + metadata: { + managedByLeanTo: 'lean_to_1', + leanToRole: 'post', + }, + }) + const handles = + typeof columnDefinition.handles === 'function' + ? columnDefinition.handles(column) + : columnDefinition.handles + + expect(handles.map((handle) => handle.kind)).toEqual([ + 'linear-resize', + 'linear-resize', + 'arc-resize', + ]) + }) +}) diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index d5557e1b68..bfcca86e2d 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -191,6 +191,17 @@ const STYLES_WITH_TOP_SPREAD = new Set([ 'v-frame', ]) +function isLeanToManagedColumn(node: ColumnNodeType): boolean { + const metadata = node.metadata + return ( + metadata !== null && + typeof metadata === 'object' && + !Array.isArray(metadata) && + metadata.managedByLeanTo !== undefined && + metadata.leanToRole === 'post' + ) +} + // Resolve the column's visible XZ footprint half-extents per supportStyle // + crossSection. Vertical supports use the shaft geometry (radius for // round / octagonal / sixteen-sided, width/depth for square / rectangular); @@ -280,7 +291,9 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor[] // - round / octagonal / sixteen-sided → single radius arrow // - square → uniform width+depth // - rectangular → width + depth (independent) - const handles: HandleDescriptor[] = [columnHeightHandle()] + const handles: HandleDescriptor[] = [] + const managedByLeanTo = isLeanToManagedColumn(node) + if (!managedByLeanTo) handles.push(columnHeightHandle()) if (node.supportStyle !== 'vertical') { handles.push(columnBraceHandle('x'), columnBraceHandle('z')) if (STYLES_WITH_BOTTOM_SPREAD.has(node.supportStyle)) { @@ -289,6 +302,9 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor[] if (STYLES_WITH_TOP_SPREAD.has(node.supportStyle)) { handles.push(columnBraceTopSpreadHandle()) } + } else if (managedByLeanTo) { + // Lean-to sync owns the post's structural height and footprint. Keep + // rotation user-owned so asymmetric styles such as K-braces can be flipped. } else if (ROUND_CROSS_SECTIONS.has(node.crossSection)) { handles.push(columnRadiusHandle()) } else if (node.crossSection === 'square') { @@ -296,7 +312,8 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor[] } else { handles.push(columnAxisHandle('x'), columnAxisHandle('z')) } - handles.push(columnRotateHandle(), columnMoveHandle()) + handles.push(columnRotateHandle()) + if (!managedByLeanTo) handles.push(columnMoveHandle()) return handles } diff --git a/packages/nodes/src/column/panel.tsx b/packages/nodes/src/column/panel.tsx index 8e3b13873c..4bcf674266 100644 --- a/packages/nodes/src/column/panel.tsx +++ b/packages/nodes/src/column/panel.tsx @@ -25,6 +25,19 @@ import { useCallback } from 'react' const SELECT_CLASS = 'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border' +const MANAGED_LEAN_TO_LAYOUT_FIELDS = new Set([ + 'position', + 'height', + 'width', + 'depth', + 'crossSection', + 'baseStyle', + 'baseHeight', + 'baseWidthScale', + 'baseDepthScale', + 'slots', +]) + const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({ value: value as ColumnPresetId, label: preset.label, @@ -177,6 +190,21 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } +function isManagedLeanToPost(node: ColumnNode): boolean { + const metadata = node.metadata + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return false + const record = metadata as Record + return record.managedByLeanTo !== undefined && record.leanToRole === 'post' +} + +function filterManagedLeanToLayoutUpdates(updates: Partial): Partial { + const filtered = { ...updates } + for (const key of MANAGED_LEAN_TO_LAYOUT_FIELDS) { + delete filtered[key] + } + return filtered +} + function presetUpdates(presetId: ColumnPresetId): Partial { const { label, ...preset } = COLUMN_PRESETS[presetId] return { @@ -273,9 +301,12 @@ export default function ColumnPanel() { const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return - updateNode(selectedId as AnyNode['id'], updates) + const nextUpdates = + node && isManagedLeanToPost(node) ? filterManagedLeanToLayoutUpdates(updates) : updates + if (Object.keys(nextUpdates).length === 0) return + updateNode(selectedId as AnyNode['id'], nextUpdates) }, - [selectedId, updateNode], + [node, selectedId, updateNode], ) const handleClose = useCallback(() => { @@ -299,6 +330,7 @@ export default function ColumnPanel() { if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null const shaftProfile = node.shaftProfile ?? 'straight' const supportStyle = node.supportStyle ?? 'vertical' + const managedByLeanTo = isManagedLeanToPost(node) const isBraceSupport = supportStyle === 'a-frame' || supportStyle === 'y-frame' || @@ -527,7 +559,13 @@ export default function ColumnPanel() { - {!isBraceSupport && ( + {managedByLeanTo && ( +

+ Height and footprint are controlled by the lean-to extension. Rotate or change the + support style here; resize from the parent lean-to. +

+ )} + {!isBraceSupport && !managedByLeanTo && ( )} - handleUpdate({ height: value })} - precision={2} - step={0.05} - unit="m" - value={node.height} - /> + {!managedByLeanTo && ( + handleUpdate({ height: value })} + precision={2} + step={0.05} + unit="m" + value={node.height} + /> + )} {isBraceSupport ? ( <> {(supportStyle === 'a-frame' || @@ -623,7 +663,7 @@ export default function ColumnPanel() { onChange={(checked) => handleUpdate({ bracePlateEnabled: checked })} /> - ) : ( + ) : !managedByLeanTo ? ( <> )} - )} + ) : null}
{!isBraceSupport && ( diff --git a/packages/nodes/src/cupola/definition.ts b/packages/nodes/src/cupola/definition.ts index c343c34d2e..cf72149a5b 100644 --- a/packages/nodes/src/cupola/definition.ts +++ b/packages/nodes/src/cupola/definition.ts @@ -153,7 +153,7 @@ export const cupolaDefinition: NodeDefinition = { presentation: { label: 'Cupola', description: 'Louvered roof lantern with a dome or pyramid cap and optional finial.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/cupola.webp' }, paletteSection: 'structure', paletteOrder: 122, }, diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 4b661634db..31ae153a0b 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -510,7 +510,7 @@ export const dormerDefinition: NodeDefinition = { presentation: { label: 'Dormer', description: 'House-shaped protrusion on a roof segment.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/dormer.webp' }, paletteSection: 'structure', paletteOrder: 125, }, diff --git a/packages/nodes/src/downspout/definition.ts b/packages/nodes/src/downspout/definition.ts index a2628fb959..0af06ccdd4 100644 --- a/packages/nodes/src/downspout/definition.ts +++ b/packages/nodes/src/downspout/definition.ts @@ -55,8 +55,12 @@ function downspoutLengthHandle(): HandleDescriptor { anchor: 'max', shape: 'tracker', min: MIN_LENGTH, + gridSnap: true, currentValue: (n) => n.length, - apply: (_n, newValue) => ({ length: Math.max(MIN_LENGTH, newValue) }), + apply: (_n, newValue) => ({ + length: Math.max(MIN_LENGTH, newValue), + lengthMode: 'manual', + }), placement: { position: (n, scene) => { const routing = resolveDownspoutRouting(n, scene) @@ -110,13 +114,14 @@ function downspoutMoveHandle(side: 'left' | 'right'): HandleDescriptor (n.gutterId ? (n.gutterId as AnyNodeId) : undefined), currentValue: (n) => readOutletOffset(n), apply: (n, newOffset, scene) => { const gutter = n.gutterId ? scene.get(n.gutterId as AnyNodeId) : undefined if (!gutter) return {} const outlets = (gutter.outlets ?? []).map((o) => - o.id === n.outletId ? { ...o, offset: newOffset } : o, + o.id === n.outletId ? { ...o, offset: newOffset, generatedBy: undefined } : o, ) // Patch targets the GUTTER (overrideTarget), not the downspout. return { outlets } as unknown as Partial @@ -155,10 +160,11 @@ const downspoutHandles: HandleDescriptor[] = [ */ export const downspoutDefinition: NodeDefinition = { kind: 'downspout', - schemaVersion: 1, + schemaVersion: 2, schema: DownspoutNode, category: 'structure', surfaceRole: 'roof', + snapProfile: 'item', defaults: () => { const stub = DownspoutNodeSchema.parse({ @@ -185,6 +191,10 @@ export const downspoutDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + system: { + module: () => import('./system'), + priority: 2, + }, preview: () => import('./preview'), tool: () => import('./tool'), @@ -197,7 +207,7 @@ export const downspoutDefinition: NodeDefinition = { presentation: { label: 'Downspout', description: 'Vertical drop pipe from a gutter outlet to the ground.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/downspout.webp' }, paletteSection: 'structure', paletteOrder: 123, }, diff --git a/packages/nodes/src/downspout/inspector-editors.tsx b/packages/nodes/src/downspout/inspector-editors.tsx index fa5c06ac25..3a8c1dcd9b 100644 --- a/packages/nodes/src/downspout/inspector-editors.tsx +++ b/packages/nodes/src/downspout/inspector-editors.tsx @@ -66,7 +66,18 @@ export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) { const handleCommit = (offset: number) => { // Commit once to the store, then drop the override. const state = useScene.getState() - state.updateNode(gutterId, { outlets: withOffset(offset) }) + const outlets = withOffset(offset).map((entry) => + entry.id === node.outletId ? { ...entry, generatedBy: undefined } : entry, + ) + const metadata = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? { ...node.metadata } + : {} + delete metadata.generatedBy + state.updateNodes([ + { id: gutterId, data: { outlets } }, + { id: node.id as AnyNodeId, data: { metadata: metadata as DownspoutNode['metadata'] } }, + ]) useLiveNodeOverrides.getState().clear(gutterId) state.markDirty(gutterId) } diff --git a/packages/nodes/src/downspout/parametrics.test.ts b/packages/nodes/src/downspout/parametrics.test.ts new file mode 100644 index 0000000000..61094faf9e --- /dev/null +++ b/packages/nodes/src/downspout/parametrics.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'bun:test' +import { DownspoutNode } from '@pascal-app/core' +import { downspoutParametrics } from './parametrics' + +describe('downspout length mode', () => { + test('switches an automatic downspout to manual when its length is edited', () => { + const node = DownspoutNode.parse({ length: 6, lengthMode: 'to-ground' }) + expect(downspoutParametrics.derive?.({ ...node, length: 4 }, { length: 4 }, node)).toEqual({ + lengthMode: 'manual', + }) + }) +}) diff --git a/packages/nodes/src/downspout/parametrics.ts b/packages/nodes/src/downspout/parametrics.ts index d877529502..b66f1e69cc 100644 --- a/packages/nodes/src/downspout/parametrics.ts +++ b/packages/nodes/src/downspout/parametrics.ts @@ -3,6 +3,7 @@ import { DownspoutPositionEditor } from './inspector-editors' import type { DownspoutNode } from './schema' export const downspoutParametrics: ParametricDescriptor = { + derive: (_next, patch) => ('length' in patch ? { lengthMode: 'manual' } : {}), groups: [ { label: 'Dimensions', diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx index a86604e4db..9eebee1fd6 100644 --- a/packages/nodes/src/downspout/renderer.tsx +++ b/packages/nodes/src/downspout/renderer.tsx @@ -97,7 +97,6 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { ? ({ ...segment, ...segmentOverrides } as RoofSegmentNode) : segment : undefined - // Routing back to the wall — memoised on the gutter/segment values // that actually move the jog or the collar bore, so the pipe geometry // only rebuilds when one of those changes (not on every override-merge diff --git a/packages/nodes/src/downspout/system.tsx b/packages/nodes/src/downspout/system.tsx new file mode 100644 index 0000000000..d4a730fbb9 --- /dev/null +++ b/packages/nodes/src/downspout/system.tsx @@ -0,0 +1,127 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type DownspoutNode, + type GutterNode, + type RoofSegmentNode, + resolveAutomaticDownspoutLength, + type SceneApi, + usesAutomaticDownspoutLength, +} from '@pascal-app/core' +import { useEffect } from 'react' + +const BROAD_AUTOMATIC_LENGTH_DEPENDENCY_TYPES = new Set([ + 'site', + 'building', + 'level', + 'wall', + 'lean-to-extension', + 'roof', +]) + +function affectedAutomaticDownspoutIds( + nodes: Readonly>, + previous: Readonly>, + changedIds: ReadonlySet, + automaticIds: ReadonlySet, +): Set { + const affected = new Set() + for (const id of changedIds) { + const current = nodes[id] + const prior = previous[id] + if (current?.type === 'downspout' && usesAutomaticDownspoutLength(current)) affected.add(id) + if (prior?.type === 'downspout') affected.add(id) + const candidate = current ?? prior + if (!candidate) continue + if (BROAD_AUTOMATIC_LENGTH_DEPENDENCY_TYPES.has(candidate.type)) { + for (const automaticId of automaticIds) affected.add(automaticId) + continue + } + if (candidate.type === 'gutter' || candidate.type === 'roof-segment') { + const segmentId = + candidate.type === 'roof-segment' + ? candidate.id + : (candidate.parentId ?? candidate.roofSegmentId) + const segment = segmentId + ? ((nodes[segmentId as AnyNodeId] ?? previous[segmentId as AnyNodeId]) as + | RoofSegmentNode + | undefined) + : undefined + for (const childId of segment?.children ?? []) { + const child = nodes[childId as AnyNodeId] ?? previous[childId as AnyNodeId] + if (child?.type === 'downspout') affected.add(child.id as AnyNodeId) + } + } + } + return affected +} + +function automaticLengthUpdates( + nodes: Record, + candidateIds: Iterable, +) { + const updates: { id: AnyNodeId; data: Partial }[] = [] + for (const id of candidateIds) { + const candidate = nodes[id] + if (candidate?.type !== 'downspout' || !usesAutomaticDownspoutLength(candidate)) continue + const downspout = candidate as DownspoutNode + const gutter = downspout.gutterId + ? (nodes[downspout.gutterId as AnyNodeId] as GutterNode | undefined) + : undefined + const segment = gutter?.roofSegmentId + ? (nodes[gutter.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined + const outlet = gutter?.outlets?.find((entry) => entry.id === downspout.outletId) + if (!(gutter?.type === 'gutter' && segment?.type === 'roof-segment' && outlet)) continue + const length = resolveAutomaticDownspoutLength(nodes, segment, gutter, outlet.offset) + if (Math.abs(length - downspout.length) > 1e-6) { + updates.push({ id: downspout.id as AnyNodeId, data: { length } as Partial }) + } + } + return updates +} + +export function initializeAutomaticDownspoutSync(sceneApi: SceneApi) { + const applyChanges = sceneApi.applyChanges + const subscribeNodes = sceneApi.subscribeNodes + if (!(applyChanges && subscribeNodes)) return () => {} + const automaticIds = new Set() + for (const node of Object.values(sceneApi.nodes())) { + if (node.type === 'downspout' && usesAutomaticDownspoutLength(node)) { + automaticIds.add(node.id as AnyNodeId) + } + } + let syncing = false + const apply = (nodes: Record, candidateIds: Iterable) => { + const updates = automaticLengthUpdates(nodes, candidateIds) + if (updates.length === 0) return + syncing = true + sceneApi.pauseHistory() + try { + applyChanges({ update: updates }) + } finally { + sceneApi.resumeHistory() + syncing = false + } + } + apply(sceneApi.nodes() as Record, automaticIds) + return subscribeNodes((nodes, previous, changedIds) => { + if (syncing) return + for (const id of changedIds) { + const node = nodes[id] + if (node?.type === 'downspout' && usesAutomaticDownspoutLength(node)) automaticIds.add(id) + else if (previous[id]?.type === 'downspout') automaticIds.delete(id) + } + const affected = affectedAutomaticDownspoutIds(nodes, previous, changedIds, automaticIds) + if (affected.size > 0) apply(nodes as Record, affected) + }) +} + +const DownspoutSystem = ({ sceneApi }: { sceneApi: SceneApi }) => { + useEffect(() => initializeAutomaticDownspoutSync(sceneApi), [sceneApi]) + return null +} + +export default DownspoutSystem diff --git a/packages/nodes/src/eyebrow-vent/definition.ts b/packages/nodes/src/eyebrow-vent/definition.ts index 097f8c6da8..2bd1e97eda 100644 --- a/packages/nodes/src/eyebrow-vent/definition.ts +++ b/packages/nodes/src/eyebrow-vent/definition.ts @@ -160,7 +160,7 @@ export const eyebrowVentDefinition: NodeDefinition = { presentation: { label: 'Eyebrow Vent', description: 'Low curved lens-shaped roof vent with a louvered front.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/eyebrow-vent.webp' }, paletteSection: 'structure', paletteOrder: 123, }, diff --git a/packages/nodes/src/gutter/corner-mitre.ts b/packages/nodes/src/gutter/corner-mitre.ts index 6322c17598..15932fea5b 100644 --- a/packages/nodes/src/gutter/corner-mitre.ts +++ b/packages/nodes/src/gutter/corner-mitre.ts @@ -45,6 +45,18 @@ export type GutterMitres = { export const NO_MITRES: GutterMitres = { left: 0, right: 0 } +function prescribedLeanToMitres(gutter: GutterNode): GutterMitres { + const metadata = gutter.metadata + if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return NO_MITRES + const value = (metadata as Record).leanToGutterMitres + if (!(value && typeof value === 'object' && !Array.isArray(value))) return NO_MITRES + const mitres = value as Record + return { + left: typeof mitres.left === 'number' && Number.isFinite(mitres.left) ? mitres.left : 0, + right: typeof mitres.right === 'number' && Number.isFinite(mitres.right) ? mitres.right : 0, + } +} + // Match the length-snap's 10 cm catch radius (`length-snap.ts`): any two // endpoints close enough for the corner snap to bind are close enough to // read as "they meant to meet". The corner snap pulls them to the exact @@ -88,15 +100,40 @@ function gutterEndpoints(g: GutterNode): { plus: Endpoint; minus: Endpoint } { const outX = Math.sin(r) const outZ = Math.cos(r) const half = g.length / 2 + const curvedEnd = (x: number, plus: boolean): Endpoint | null => { + const arc = g.arc + if (!arc || !Number.isFinite(arc.radius)) return null + const signedRef = (Math.sign(arc.centerZ) || 1) * arc.radius + const phi = (x - arc.centerX) / signedRef + const radial = -arc.centerZ + const bentX = arc.centerX - radial * Math.sin(phi) + const bentZ = arc.centerZ + radial * Math.cos(phi) + const tangentX = Math.cos(phi) + const tangentZ = Math.sin(phi) + const radialX = -Math.sin(phi) + const radialZ = Math.cos(phi) + const rotate = (xValue: number, zValue: number): [number, number] => [ + xValue * dirX + zValue * outX, + xValue * dirZ + zValue * outZ, + ] + const [worldX, worldZ] = rotate(bentX, bentZ) + const [tangentWorldX, tangentWorldZ] = rotate(tangentX, tangentZ) + const [outWorldX, outWorldZ] = rotate(radialX, radialZ) + return { + pos: [px + worldX, py, pz + worldZ], + awayDir: plus ? [-tangentWorldX, -tangentWorldZ] : [tangentWorldX, tangentWorldZ], + outDir: [outWorldX, outWorldZ], + } + } return { - plus: { + plus: curvedEnd(half, true) ?? { pos: [px + dirX * half, py, pz + dirZ * half], // From the +X endpoint, the rest of the gutter extends back // toward the −X end — so "away from this end" is −dir. awayDir: [-dirX, -dirZ], outDir: [outX, outZ], }, - minus: { + minus: curvedEnd(-half, false) ?? { pos: [px - dirX * half, py, pz - dirZ * half], awayDir: [dirX, dirZ], outDir: [outX, outZ], @@ -225,11 +262,12 @@ export function computeGutterMitres( subjectSegment: Pick, siblings: readonly GutterWithSegment[], ): GutterMitres { - if (siblings.length === 0) return NO_MITRES + const prescribed = prescribedLeanToMitres(subject) + if (siblings.length === 0) return prescribed const subj = gutterEndpointsInFrame(subject, subjectSegment) - let leftMitre = 0 - let rightMitre = 0 + let leftMitre = prescribed.left + let rightMitre = prescribed.right for (const sib of siblings) { if (sib.gutter.id === subject.id) continue @@ -251,10 +289,10 @@ export function computeGutterMitres( if (!otherPlusAtCorner && !otherMinusAtCorner) continue const otherEnd = otherPlusAtCorner ? other.plus : other.minus - if (leftMitre === 0 && planDistSq(subj.minus.pos, corner) <= CORNER_EPSILON_SQ) { + if (planDistSq(subj.minus.pos, corner) <= CORNER_EPSILON_SQ) { leftMitre = mitreBetween(subj.minus, otherEnd) } - if (rightMitre === 0 && planDistSq(subj.plus.pos, corner) <= CORNER_EPSILON_SQ) { + if (planDistSq(subj.plus.pos, corner) <= CORNER_EPSILON_SQ) { rightMitre = mitreBetween(subj.plus, otherEnd) } if (leftMitre !== 0 && rightMitre !== 0) break diff --git a/packages/nodes/src/gutter/curved-arc.test.ts b/packages/nodes/src/gutter/curved-arc.test.ts new file mode 100644 index 0000000000..65080a6aa9 --- /dev/null +++ b/packages/nodes/src/gutter/curved-arc.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'bun:test' +import { GutterNode } from '@pascal-app/core' +import * as THREE from 'three' +import { buildGutterGeometry } from './geometry' +import { resolveGutterOutletById } from './outlet-lookup' + +// A managed lean-to gutter following a curved eave carries a concentric arc in +// gutter-mesh-local coordinates. The bend rotates each vertex about the stored +// center O = (centerX, centerZ), so its distance from O is preserved — the +// trough hugs the eave circle instead of ballooning off the chord. +describe('curved gutter arc', () => { + const radius = 5 + const centerX = 0 + // Center sits one radius inward along -Z so the trough floor (Z ≈ 0) lands on + // the eave circle of radius `radius`. + const centerZ = -radius + + function curvedGutter(overrides: Record = {}) { + return GutterNode.parse({ + id: 'gutter_curved', + type: 'gutter', + length: 3, + size: 0.13, + profile: 'k-style', + arc: { centerX, centerZ, radius }, + ...overrides, + }) + } + + test('bends every trough triangle into a thin concentric band on both wall sides', () => { + for (const arcCenterZ of [-radius, radius]) { + const geometry = buildGutterGeometry( + curvedGutter({ + length: 8, + arc: { centerX, centerZ: arcCenterZ, radius }, + outlets: [{ id: 'outlet_arc', offset: 0.5, diameter: 0.07 }], + }), + ) + const source = geometry.index ? geometry.toNonIndexed() : geometry + const position = source.getAttribute('position') + expect(position.count).toBeGreaterThan(0) + + const distanceToEdge = (a: number, b: number) => { + const ax = position.getX(a) - centerX + const az = position.getZ(a) - arcCenterZ + const dx = position.getX(b) - position.getX(a) + const dz = position.getZ(b) - position.getZ(a) + const lengthSquared = dx * dx + dz * dz + const t = + lengthSquared > 1e-12 ? Math.max(0, Math.min(1, -(ax * dx + az * dz) / lengthSquared)) : 0 + return Math.hypot(ax + dx * t, az + dz * t) + } + let minR = Number.POSITIVE_INFINITY + let maxR = Number.NEGATIVE_INFINITY + let minimumTriangleEdgeRadius = Number.POSITIVE_INFINITY + for (let i = 0; i < position.count; i++) { + const d = Math.hypot(position.getX(i) - centerX, position.getZ(i) - arcCenterZ) + minR = Math.min(minR, d) + maxR = Math.max(maxR, d) + } + for (let offset = 0; offset + 2 < position.count; offset += 3) { + minimumTriangleEdgeRadius = Math.min( + minimumTriangleEdgeRadius, + distanceToEdge(offset, offset + 1), + distanceToEdge(offset + 1, offset + 2), + distanceToEdge(offset + 2, offset), + ) + } + + expect(minR).toBeGreaterThan(radius - 0.3) + expect(maxR).toBeLessThan(radius + 0.3) + expect(minimumTriangleEdgeRadius).toBeGreaterThan(radius - 0.3) + + if (source !== geometry) source.dispose() + geometry.dispose() + } + }) + + test('places an outlet on the eave circle', () => { + const gutter = curvedGutter({ + outlets: [{ id: 'outlet_a', offset: 0.5, diameter: 0.07 }], + }) + const placement = resolveGutterOutletById(gutter, 'outlet_a') + expect(placement).not.toBeNull() + const d = Math.hypot(placement!.x - centerX, placement!.z - centerZ) + // The drop tube mounts on the bent trough floor — on the eave circle, offset + // only by the profile's floor midpoint (well under one profile `size`). + expect(d).toBeGreaterThan(radius - 0.01) + expect(d).toBeLessThan(radius + gutter.size) + }) + + test('keeps the curved front fascia continuous across a downspout outlet', () => { + const outerRadius = 7.25 + const outerCenterZ = -9.548 + const offset = 3.94 + const gutter = curvedGutter({ + length: 8.2, + arc: { centerX, centerZ: outerCenterZ, radius: outerRadius }, + outlets: [{ id: 'outlet_fascia', offset, diameter: 0.07 }], + }) + const geometry = buildGutterGeometry(gutter) + const signedRadius = -outerRadius + const phi = offset / signedRadius + const radial = new THREE.Vector3(-Math.sin(phi), 0, Math.cos(phi)) + const raycaster = new THREE.Raycaster( + new THREE.Vector3(centerX, -gutter.size * 0.4, outerCenterZ).addScaledVector( + radial, + Math.abs(outerCenterZ) + 1, + ), + radial.clone().negate(), + 0, + 2, + ) + const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }) + const intersections = raycaster.intersectObject(new THREE.Mesh(geometry, material)) + + expect(intersections.length).toBeGreaterThan(0) + + material.dispose() + geometry.dispose() + }) + + test('leaves a straight gutter (no arc) unbent', () => { + const gutter = GutterNode.parse({ id: 'gutter_straight', type: 'gutter', length: 3 }) + const geometry = buildGutterGeometry(gutter) + const position = geometry.getAttribute('position') + // Without an arc the length axis stays straight: X spans the full run. + let maxX = Number.NEGATIVE_INFINITY + for (let i = 0; i < position.count; i++) maxX = Math.max(maxX, Math.abs(position.getX(i))) + expect(maxX).toBeGreaterThan(1) + geometry.dispose() + }) +}) diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index 7f40834734..a1dfdb15c4 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -42,9 +42,9 @@ function getRimZ(n: GutterNodeType): number { // // Corner snap: when the dragged endpoint nears the geometric corner it // would form with another gutter (the crossing of their length axes), -// `snapLengthToCorner` overrides the raw newLength so the endpoint lands -// EXACTLY on that corner — the corner-mitre detector then fires reliably -// without pixel-perfect dragging. Only this gutter's length changes. +// `snapLengthToCorner` is the handle's magnetic snap, so Lines mode lands the +// endpoint exactly on that corner, Grid mode uses the chosen step, and Off +// leaves the cursor raw. Only this gutter's length changes. function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor { const sign = side === 'right' ? 1 : -1 return { @@ -52,14 +52,15 @@ function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor n.length, - apply: (initial, newLength, sceneApi) => { + magneticSnap: (initial, newLength, sceneApi) => { const rotY = initial.rotation ?? 0 const armX = Math.cos(rotY) const armZ = -Math.sin(rotY) const anchorX = initial.position[0] - sign * (initial.length / 2) * armX const anchorZ = initial.position[2] - sign * (initial.length / 2) * armZ - const snap = snapLengthToCorner( + return snapLengthToCorner( initial, newLength, sign, @@ -69,14 +70,18 @@ function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor { + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initial.length / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.length / 2) * armZ + const newCenterX = anchorX + sign * (newLength / 2) * armX + const newCenterZ = anchorZ + sign * (newLength / 2) * armZ return { - length: snap.length, + length: newLength, position: [newCenterX, initial.position[1], newCenterZ], } }, @@ -101,6 +106,7 @@ function gutterSizeHandle(): HandleDescriptor { // downward grows the value 1:1. anchor: 'max', min: MIN_SIZE, + gridSnap: true, currentValue: (n) => n.size, apply: (_n, newValue) => ({ size: Math.max(MIN_SIZE, newValue) }), placement: { @@ -134,10 +140,11 @@ const gutterHandles: HandleDescriptor[] = [ */ export const gutterDefinition: NodeDefinition = { kind: 'gutter', - schemaVersion: 1, + schemaVersion: 2, schema: GutterNode, category: 'structure', surfaceRole: 'roof', + snapProfile: 'item', defaults: () => { const stub = GutterNodeSchema.parse({ @@ -182,7 +189,7 @@ export const gutterDefinition: NodeDefinition = { presentation: { label: 'Gutter', description: 'Rain-water channel running along the eave of a roof segment.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/gutter.webp' }, paletteSection: 'structure', paletteOrder: 122, }, diff --git a/packages/nodes/src/gutter/eave-align.ts b/packages/nodes/src/gutter/eave-align.ts index 7ca20cbd7f..e55451e38f 100644 --- a/packages/nodes/src/gutter/eave-align.ts +++ b/packages/nodes/src/gutter/eave-align.ts @@ -34,6 +34,13 @@ export type GutterWithSegment = { segment: RoofSegmentNode } +function prescribedLeanToEaveY(gutter: GutterNode): number | null { + const metadata = gutter.metadata + if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return null + const value = (metadata as Record).leanToGutterEaveY + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + function guttersMeet( a: GutterNode, aSeg: RoofSegmentNode, @@ -64,6 +71,8 @@ export function computeSharedEaveY( subjectSegment: RoofSegmentNode, siblings: readonly GutterWithSegment[], ): number { + const prescribed = prescribedLeanToEaveY(subject) + if (prescribed !== null) return prescribed const subjectBaseY = subjectSegment.position?.[1] ?? 0 if (siblings.length === 0) return computeEaveY(subjectSegment) diff --git a/packages/nodes/src/gutter/eave-snap.ts b/packages/nodes/src/gutter/eave-snap.ts index a0daabbb1b..6638f03dd7 100644 --- a/packages/nodes/src/gutter/eave-snap.ts +++ b/packages/nodes/src/gutter/eave-snap.ts @@ -1,4 +1,10 @@ -import type { RoofSegmentNode, RoofType } from '@pascal-app/core' +import { + computeGutterEaveY, + GUTTER_EAVE_TUCK_INWARD, + GUTTER_EAVE_TUCK_UP, + type RoofSegmentNode, + type RoofType, +} from '@pascal-app/core' /** * Shared eave-snap math for the gutter's placement + move tools. @@ -21,8 +27,8 @@ import type { RoofSegmentNode, RoofType } from '@pascal-app/core' // drip-edge. These tuck the snap so the gutter reads as "attached to // the fascia" rather than "floating at the very tip of the overhang". // Tuned by feel — bump them up if the gutter looks too low / outboard. -export const EAVE_TUCK_INWARD = 0.04 -export const EAVE_TUCK_UP = 0.04 +export const EAVE_TUCK_INWARD = GUTTER_EAVE_TUCK_INWARD +export const EAVE_TUCK_UP = GUTTER_EAVE_TUCK_UP export type EaveSide = '+X' | '-X' | '+Z' | '-Z' @@ -53,17 +59,7 @@ export type EaveSnap = { export function computeEaveY( segment: Pick, ): number { - const wallHeight = segment.wallHeight ?? 0 - // Flat roofs have no slope drop and no slope-surface-vs-deck-top - // offset — the deck top IS the eave line. EAVE_TUCK_UP is a - // correction that lifts a SLOPED gutter from the slope-surface up to - // the deck-top line; applying it to a flat deck floats the gutter - // above the roof and leaves a visible gap between the edge and the - // gutter. So mount flat gutters right at the deck top. - if ((segment.roofType ?? 'gable') === 'flat') return wallHeight - const overhang = segment.overhang ?? 0 - const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 - return wallHeight - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP + return computeGutterEaveY(segment) } /** diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index 08ce9ab37a..d0722694b7 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -100,7 +100,7 @@ export function buildGutterGeometry( depth: channelLen, bevelEnabled: false, curveSegments: 16, - steps: 1, + steps: gutterArcSteps(node, channelLen), }) // Apply the corner-mitre skew while we're still in the source frame. // Source axes (pre-rotation): X_cs = outward, Y_cs = vertical, @@ -158,7 +158,7 @@ export function buildGutterGeometry( depth: capLeftLen, bevelEnabled: false, curveSegments: 16, - steps: 1, + steps: gutterArcSteps(node, capLeftLen), }) leftCap.rotateY(-Math.PI / 2) // Left cap spans [-len/2, -len/2 + capLeftLen]: translate by @@ -173,7 +173,7 @@ export function buildGutterGeometry( depth: capRightLen, bevelEnabled: false, curveSegments: 16, - steps: 1, + steps: gutterArcSteps(node, capRightLen), }) rightCap.rotateY(-Math.PI / 2) // Right cap spans [+len/2 - capRightLen, +len/2]. @@ -217,7 +217,11 @@ export function buildGutterGeometry( // CSG drill — punches each bore through the merged geometry. Runs // last so the floor + collars are already in one mesh; each drill // cuts both at once, subtracted sequentially. - if (placements.length > 0) { + // Subtracting a near-end outlet from an already subdivided curved run makes + // three-bvh-csg discard the complete cross-section around the drill, leaving + // a visible break in the fascia. Keep the curved trough watertight; its collar + // and connected downspout still conceal the floor where the bore would sit. + if (placements.length > 0 && !node.arc) { let workingBrush = new Brush(merged) prepareBrushForCSG(workingBrush) for (const p of placements) { @@ -234,10 +238,118 @@ export function buildGutterGeometry( } const cutGeometry = csgGeometry(workingBrush) merged.dispose() - return cutGeometry + return bendGutterGeometryAlongArc(cutGeometry, node, mitres) } - return merged + return bendGutterGeometryAlongArc(merged, node, mitres) +} + +function gutterArcSteps(node: GutterNode, length: number): number { + if (!node.arc || !Number.isFinite(node.arc.radius)) return 1 + return Math.max(1, Math.min(32, Math.ceil(length / 0.4))) +} + +// Bend the finished straight gutter (length along mesh-+X, outward along mesh-+Z) +// onto its stored concentric arc. Each vertex keeps its vertical Y; its (x, z) rotate +// about the arc center by the angle its along-length coordinate subtends, so the trough +// hugs the same circle as the deck's eave. Absent `arc` is a straight no-op. +function bendGutterGeometryAlongArc( + geometry: THREE.BufferGeometry, + node: GutterNode, + mitres: GutterMitres, +): THREE.BufferGeometry { + const arc = node.arc + if (!arc || !Number.isFinite(arc.radius)) return geometry + const signedRef = (Math.sign(arc.centerZ) || 1) * arc.radius + const position = geometry.attributes.position! + const metadata = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + const rawStraightEnds = metadata.leanToGutterArcStraightEnds + const straightEnds = + rawStraightEnds && typeof rawStraightEnds === 'object' && !Array.isArray(rawStraightEnds) + ? (rawStraightEnds as Record) + : {} + const straightEnd = (side: 'left' | 'right') => { + const raw = straightEnds[side] + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null + const value = raw as Record + return typeof value.startX === 'number' && typeof value.endX === 'number' + ? { startX: value.startX, endX: value.endX } + : null + } + const leftStraight = straightEnd('left') + const rightStraight = straightEnd('right') + const bendPoint = (x: number, z: number, phi: number) => { + const radial = z - arc.centerZ + return { + x: arc.centerX - radial * Math.sin(phi), + z: arc.centerZ + radial * Math.cos(phi), + } + } + const bendMitredEnd = (x: number, z: number, endX: number) => { + const phi = (endX - arc.centerX) / signedRef + const base = bendPoint(endX, z, phi) + const extension = x - endX + return { + x: base.x + extension * Math.cos(phi), + z: base.z + extension * Math.sin(phi), + } + } + const bendStraightEnd = (x: number, z: number, transition: { startX: number; endX: number }) => { + const startPhi = (transition.startX - arc.centerX) / signedRef + const endPhi = (transition.endX - arc.centerX) / signedRef + const start = bendPoint(transition.startX, 0, startPhi) + const end = bendPoint(transition.endX, 0, endPhi) + const spanX = transition.endX - transition.startX + const direction = Math.sign(spanX) || 1 + const beyondEnd = Math.max(0, (x - transition.endX) * direction) + const pathX = x - beyondEnd * direction + const ratio = Math.abs(spanX) > 1e-6 ? (pathX - transition.startX) / spanX : 0 + let centerX = start.x + (end.x - start.x) * ratio + let centerZ = start.z + (end.z - start.z) * ratio + const chordLength = Math.hypot(end.x - start.x, end.z - start.z) + const tangentX = chordLength > 1e-6 ? (end.x - start.x) / chordLength : Math.cos(startPhi) + const tangentZ = chordLength > 1e-6 ? (end.z - start.z) / chordLength : Math.sin(startPhi) + centerX += beyondEnd * tangentX + centerZ += beyondEnd * tangentZ + let normalX = -tangentZ + let normalZ = tangentX + const midPhi = (startPhi + endPhi) / 2 + if (normalX * -Math.sin(midPhi) + normalZ * Math.cos(midPhi) < 0) { + normalX = -normalX + normalZ = -normalZ + } + return { x: centerX + z * normalX, z: centerZ + z * normalZ } + } + const rightTan = Math.tan(mitres.right) + const leftTan = Math.tan(mitres.left) + const halfLength = Math.max(0.05, node.length) / 2 + const endEpsilon = 1e-4 + for (let index = 0; index < position.count; index++) { + const x = position.getX(index) + const z = position.getZ(index) + const onRightMitre = + mitres.right !== 0 && Math.abs(x - (halfLength + z * rightTan)) < endEpsilon + const onLeftMitre = mitres.left !== 0 && Math.abs(x - (-halfLength - z * leftTan)) < endEpsilon + const onRightStraight = rightStraight && x >= rightStraight.startX - endEpsilon + const onLeftStraight = leftStraight && x <= leftStraight.startX + endEpsilon + const bent = onRightStraight + ? bendStraightEnd(x, z, rightStraight) + : onLeftStraight + ? bendStraightEnd(x, z, leftStraight) + : onRightMitre + ? bendMitredEnd(x, z, halfLength) + : onLeftMitre + ? bendMitredEnd(x, z, -halfLength) + : bendPoint(x, z, (x - arc.centerX) / signedRef) + position.setX(index, bent.x) + position.setZ(index, bent.z) + } + position.needsUpdate = true + geometry.computeVertexNormals() + return geometry } // Remove the extrude's cross-section CAP triangles at a mitred end so two diff --git a/packages/nodes/src/gutter/outlet-lookup.ts b/packages/nodes/src/gutter/outlet-lookup.ts index e2ea724379..457bed827c 100644 --- a/packages/nodes/src/gutter/outlet-lookup.ts +++ b/packages/nodes/src/gutter/outlet-lookup.ts @@ -62,11 +62,33 @@ function placeOutlet( const maxX = len / 2 - capRightLen - outerHalfX if (maxX <= minX) return null const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0)) + const z = profileFloorMidZ(gutter.profile ?? 'k-style', size) + + // Straight run: the along-length X and outward Z are already the + // mesh-local center. A managed lean-to gutter following a curved wall + // carries a concentric arc, so the trough floor bends along its length — + // remap (x, z) onto the same arc the geometry uses so the drop tube mounts + // on the actual bent floor rather than the straight chord. + const arc = gutter.arc + if (arc && Number.isFinite(arc.radius)) { + const signedRef = (Math.sign(arc.centerZ) || 1) * arc.radius + const phi = (x - arc.centerX) / signedRef + const radial = z - arc.centerZ + return { + x: arc.centerX - radial * Math.sin(phi), + y: -size, + z: arc.centerZ + radial * Math.cos(phi), + bore: inner.halfX, + shape, + innerHalfX: inner.halfX, + innerHalfZ: inner.halfZ, + } + } return { x, y: -size, - z: profileFloorMidZ(gutter.profile ?? 'k-style', size), + z, bore: inner.halfX, shape, innerHalfX: inner.halfX, diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts index 6ecc6cdeb9..cf1f68b377 100644 --- a/packages/nodes/src/gutter/parametrics.ts +++ b/packages/nodes/src/gutter/parametrics.ts @@ -57,6 +57,10 @@ export const gutterParametrics: ParametricDescriptor = { ], }, ], + onDeleteCascade: (node, nodes) => + Object.values(nodes) + .filter((candidate) => candidate.type === 'downspout' && candidate.gutterId === node.id) + .map((candidate) => candidate.id), // Lazy-loaded section that lists every downspout attached to this // gutter and offers an Add button at the bottom. Outlets are created // and removed through this panel (and the downspout placement tool) — diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index b9adcde28b..b3684219a8 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -24,6 +24,7 @@ import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner import { computeSharedEaveY } from './eave-align' import { computeEaveY } from './eave-snap' import { buildGutterGeometry } from './geometry' +import { segmentForGutterTrimClip } from './trim-clip' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, @@ -31,6 +32,20 @@ const defaultMaterial = new THREE.MeshStandardMaterial({ metalness: 0.25, }) +function leanToJointMitres(node: GutterNode) { + const metadata = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + const value = metadata.leanToGutterMitres + if (!(value && typeof value === 'object' && !Array.isArray(value))) return NO_MITRES + const mitres = value as Record + return { + left: typeof mitres.left === 'number' && Number.isFinite(mitres.left) ? mitres.left : 0, + right: typeof mitres.right === 'number' && Number.isFinite(mitres.right) ? mitres.right : 0, + } +} + /** * Gutter renderer. Mounts at the eave of the host roof-segment — the * gutter hangs level off the eave line (gravity wins; no slope tilt). @@ -100,6 +115,10 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { : undefined if (!roof) return [] as (GutterNode | RoofSegmentNode)[] const out: (GutterNode | RoofSegmentNode)[] = [] + const managedByLeanTo = + typeof (node.metadata as Record | undefined)?.managedByLeanTo === 'string' + ? ((node.metadata as Record).managedByLeanTo as string) + : null for (const sid of roof.children ?? []) { const s = state.nodes[sid as AnyNodeId] if (s?.type !== 'roof-segment') continue @@ -109,6 +128,18 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { if (g?.type === 'gutter' && g.id !== storeNode.id) out.push(g as GutterNode) } } + if (managedByLeanTo) { + for (const candidate of Object.values(state.nodes)) { + if (candidate?.type !== 'gutter' || candidate.id === storeNode.id) continue + const candidateMetadata = candidate.metadata as Record | undefined + if (typeof candidateMetadata?.managedByLeanTo !== 'string') continue + const segment = candidate.roofSegmentId + ? (state.nodes[candidate.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined + if (!segment || out.some((node) => node.id === segment.id)) continue + out.push(segment, candidate as GutterNode) + } + } return out }), ) @@ -159,10 +190,15 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { effectiveSegment?.roofType, mitreNodes, ]) + const jointMitres = leanToJointMitres(node) + const renderedMitres = { + left: jointMitres.left || mitres.left, + right: jointMitres.right || mitres.right, + } // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( - () => buildGutterGeometry(node, mitres), + () => buildGutterGeometry(node, renderedMitres), [ node.length, node.size, @@ -172,11 +208,14 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { node.endCapRight, node.hangerStyle, node.hangerSpacing, + node.arc?.centerX, + node.arc?.centerZ, + node.arc?.radius, // Value-compare the outlets array so the CSG drills only rebuild // when an outlet's offset / diameter changes or one is added. JSON.stringify(node.outlets), - mitres.left, - mitres.right, + renderedMitres.left, + renderedMitres.right, ], ) useEffect(() => () => geometry.dispose(), [geometry]) @@ -215,7 +254,11 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { ), [node.position[0], node.position[2], node.rotation, liveEaveYForClip], ) - const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment) + const clippedGeometry = useSegmentTrimClippedGeometry( + geometry, + segmentForGutterTrimClip(node, effectiveSegment), + localToSegment, + ) if (!segment || !effectiveSegment) return null diff --git a/packages/nodes/src/gutter/trim-clip.test.ts b/packages/nodes/src/gutter/trim-clip.test.ts new file mode 100644 index 0000000000..b7d6d40b1e --- /dev/null +++ b/packages/nodes/src/gutter/trim-clip.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import type { GutterNode, RoofSegmentNode } from '@pascal-app/core' +import { segmentForGutterTrimClip } from './trim-clip' + +describe('gutter segment trim clipping', () => { + test('does not apply straight trim planes to a curved gutter', () => { + const gutter = { + arc: { centerX: 0, centerZ: -9.548, radius: 7.25 }, + } as Pick + const segment = { + arc: { centerX: 0, centerZ: -8.438, radius: 7.25 }, + trim: { back: 0.002 }, + } as RoofSegmentNode + + expect(segmentForGutterTrimClip(gutter, segment)).toBeUndefined() + }) + + test('keeps trim clipping for a straight gutter', () => { + const gutter = { arc: undefined } as Pick + const segment = { arc: undefined, trim: { back: 0.2 } } as RoofSegmentNode + + expect(segmentForGutterTrimClip(gutter, segment)).toBe(segment) + }) +}) diff --git a/packages/nodes/src/gutter/trim-clip.ts b/packages/nodes/src/gutter/trim-clip.ts new file mode 100644 index 0000000000..4c263b7b2e --- /dev/null +++ b/packages/nodes/src/gutter/trim-clip.ts @@ -0,0 +1,10 @@ +import type { GutterNode, RoofSegmentNode } from '@pascal-app/core' + +export function segmentForGutterTrimClip( + gutter: Pick, + segment: RoofSegmentNode | undefined, +): RoofSegmentNode | undefined { + // Segment trim cutters are axis-aligned boxes. On a long arc, the back cutter + // crosses the gutter twice and removes two unrelated sections of the run. + return gutter.arc && segment?.arc ? undefined : segment +} diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 4923566ca1..44ab8f2406 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -21,6 +21,7 @@ import { guideDefinition } from './guide' import { gutterDefinition } from './gutter' import { hvacEquipmentDefinition } from './hvac-equipment' import { itemDefinition } from './item' +import { leanToExtensionDefinition } from './lean-to-extension' import { levelDefinition } from './level' import { linesetDefinition } from './lineset' import { liquidLineDefinition } from './liquid-line' @@ -71,6 +72,7 @@ export const builtinPlugin: Plugin = { blockDefinition as unknown as AnyNodeDefinition, spawnDefinition as unknown as AnyNodeDefinition, wallDefinition as unknown as AnyNodeDefinition, + leanToExtensionDefinition as unknown as AnyNodeDefinition, fenceDefinition as unknown as AnyNodeDefinition, slabDefinition as unknown as AnyNodeDefinition, ceilingDefinition as unknown as AnyNodeDefinition, @@ -160,6 +162,7 @@ export { guideDefinition } from './guide' export { gutterDefinition } from './gutter' export { hvacEquipmentDefinition } from './hvac-equipment' export { itemDefinition } from './item' +export { leanToExtensionDefinition } from './lean-to-extension' export { levelDefinition } from './level' export { linesetDefinition } from './lineset' export { liquidLineDefinition, useLiquidLineToolOptions } from './liquid-line' diff --git a/packages/nodes/src/lean-to-extension/arc.test.ts b/packages/nodes/src/lean-to-extension/arc.test.ts new file mode 100644 index 0000000000..d291a0edeb --- /dev/null +++ b/packages/nodes/src/lean-to-extension/arc.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test' +import { bendLocalPoint, bendRotationYAtLocalX, isCurvedLeanTo, leanToArcRadius } from './arc' + +// Distance of a bent point from the stored arc center O = (0, spanArcCenterZ). +function radiusFromCenter(node: { spanArcCenterZ: number }, x: number, y: number): number { + return Math.hypot(x - 0, y - node.spanArcCenterZ) +} + +describe('lean-to local span arc', () => { + test('straight span degenerates to the identity', () => { + const node = { spanArcCenterZ: undefined, spanArcRadius: undefined } + expect(isCurvedLeanTo(node)).toBe(false) + expect(leanToArcRadius(node)).toBe(Number.POSITIVE_INFINITY) + expect(bendLocalPoint(node, 1.5, 0.8)).toEqual({ x: 1.5, y: 0.8 }) + expect(bendLocalPoint(node, -2, -0.5)).toEqual({ x: -2, y: -0.5 }) + expect(bendRotationYAtLocalX(node, 1.5)).toBe(0) + }) + + test('reports the stored wall radius', () => { + const node = { spanArcCenterZ: 5, spanArcRadius: 4.25 } + expect(isCurvedLeanTo(node)).toBe(true) + expect(leanToArcRadius(node)).toBeCloseTo(4.25, 6) + }) + + test('uses the true wall radius for angular travel on an offset wall face', () => { + const node = { spanArcCenterZ: 4.9, spanArcRadius: 5 } + const point = bendLocalPoint(node, 1, 0) + + expect(point.x).toBeCloseTo(4.9 * Math.sin(1 / 5), 6) + expect(point.y).toBeCloseTo(4.9 - 4.9 * Math.cos(1 / 5), 6) + expect(bendRotationYAtLocalX(node, 1)).toBeCloseTo(-1 / 5, 6) + }) + + test('pins the crown high edge at the local origin', () => { + const node = { spanArcCenterZ: 5, spanArcRadius: 5 } + const mid = bendLocalPoint(node, 0, 0) + expect(mid.x).toBeCloseTo(0, 6) + expect(mid.y).toBeCloseTo(0, 6) + }) + + test('the back edge is a concentric arc at radius |spanArcCenterZ|', () => { + const node = { spanArcCenterZ: 5, spanArcRadius: 5 } + // localZ = 0 is the high/back edge; every point along it is equidistant + // from the stored center, i.e. a circular arc. + for (const localX of [-2, -1, 0, 1, 2]) { + const p = bendLocalPoint(node, localX, 0) + expect(radiusFromCenter(node, p.x, p.y)).toBeCloseTo(5, 6) + } + }) + + test('the front edge is concentric at radius |spanArcCenterZ| - depth (no balloon)', () => { + const node = { spanArcCenterZ: 5, spanArcRadius: 5 } + const depth = 1.5 + // The front/low edge stays a concentric arc one depth inward — it must + // never fan out to or across the center (the old sagitta balloon bug). + for (const localX of [-2, 0, 2]) { + const p = bendLocalPoint(node, localX, depth) + expect(radiusFromCenter(node, p.x, p.y)).toBeCloseTo(5 - depth, 6) + } + }) + + test('outward localZ pushes one unit along the crown normal', () => { + const node = { spanArcCenterZ: 5, spanArcRadius: 5 } + // At the crown the normal is axis-aligned (+Y), so a unit of localZ + // moves the point exactly one unit along +Y from the bent high edge. + const base = bendLocalPoint(node, 0, 0) + const out = bendLocalPoint(node, 0, 1) + expect(out.x).toBeCloseTo(0, 6) + expect(out.y - base.y).toBeCloseTo(1, 6) + }) + + test('member yaw is flat at the crown and tilts toward the ends', () => { + const node = { spanArcCenterZ: 5, spanArcRadius: 5 } + expect(bendRotationYAtLocalX(node, 0)).toBeCloseTo(0, 6) + const leftYaw = bendRotationYAtLocalX(node, -2) + const rightYaw = bendRotationYAtLocalX(node, 2) + expect(Math.abs(leftYaw)).toBeGreaterThan(1e-3) + expect(leftYaw).toBeCloseTo(-rightYaw, 6) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/arc.ts b/packages/nodes/src/lean-to-extension/arc.ts new file mode 100644 index 0000000000..12bc874535 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/arc.ts @@ -0,0 +1,86 @@ +import type { LeanToExtensionNode, Point2D } from '@pascal-app/core' + +const CURVE_EPSILON = 1e-6 + +// The lean-to bends along the host wall's true circular arc. The arc is stored on +// the node as a center + radius in the lean-to's local frame (local X = along the +// span, local Z = outward/projection). Because the anchor frame is sampled at the +// span center, the arc center lies on the local Z axis at `spanArcCenterZ` (local +// X = 0); `spanArcRadius` is the wall's true radius, kept for reference/tests. +// +// A flat local point (fx, fz) is bent concentrically about O = (0, cz): +// phi = fx / signed wall radius (fx is centerline arc length) +// x = -(fz - cz) * sin(phi) +// z = cz + (fz - cz) * cos(phi) +// This is exact for any arc extent and reduces to the identity at the crown, with +// +localX -> +local x for either sign of cz. When the node has no arc descriptor the +// helpers are the identity, so straight lean-tos are byte-for-byte unchanged. + +export type LeanToArcLike = Pick + +export type LeanToArcFrame = { + point: Point2D + tangent: Point2D + normal: Point2D + rotationY: number +} + +export function isCurvedLeanTo(node: LeanToArcLike): boolean { + const cz = node.spanArcCenterZ + const radius = node.spanArcRadius + return ( + cz != null && + Number.isFinite(cz) && + Math.abs(cz) > CURVE_EPSILON && + radius != null && + Number.isFinite(radius) + ) +} + +// Map a straight local-frame point onto the bent strip. +export function bendLocalPoint(node: LeanToArcLike, localX: number, localZ: number): Point2D { + if (!isCurvedLeanTo(node)) return { x: localX, y: localZ } + const cz = node.spanArcCenterZ as number + const signedRadius = (Math.sign(cz) || 1) * (node.spanArcRadius as number) + const phi = localX / signedRadius + const radial = localZ - cz + return { + x: -radial * Math.sin(phi), + y: cz + radial * Math.cos(phi), + } +} + +// Yaw (about local Y) that aligns local +X with the arc tangent. The tangent at +// angle phi is (cos phi, sin phi) in the local x-z plane; matching it under the YXZ +// convention (local +X -> (cos a, 0, -sin a)) gives a = atan2(-sin phi, cos phi) = -phi. +export function bendRotationYAtLocalX(node: LeanToArcLike, localX: number): number { + if (!isCurvedLeanTo(node)) return 0 + const cz = node.spanArcCenterZ as number + const signedRadius = (Math.sign(cz) || 1) * (node.spanArcRadius as number) + return -(localX / signedRadius) +} + +export function leanToArcFrameAtLocalX(node: LeanToArcLike, localX: number): LeanToArcFrame { + if (!isCurvedLeanTo(node)) { + return { + point: { x: localX, y: 0 }, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + rotationY: 0, + } + } + const cz = node.spanArcCenterZ as number + const signedRadius = (Math.sign(cz) || 1) * (node.spanArcRadius as number) + const phi = localX / signedRadius + return { + point: bendLocalPoint(node, localX, 0), + tangent: { x: Math.cos(phi), y: Math.sin(phi) }, + normal: { x: -Math.sin(phi), y: Math.cos(phi) }, + rotationY: -phi, + } +} + +// Radius of the local span arc (Infinity when straight). +export function leanToArcRadius(node: LeanToArcLike): number { + return isCurvedLeanTo(node) ? (node.spanArcRadius as number) : Number.POSITIVE_INFINITY +} diff --git a/packages/nodes/src/lean-to-extension/assembly.test.ts b/packages/nodes/src/lean-to-extension/assembly.test.ts new file mode 100644 index 0000000000..1ec83c15b7 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/assembly.test.ts @@ -0,0 +1,628 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + BuildingNode, + getRoofSegmentVisibleTopBounds, + getWallCurveLength, + LeanToExtensionNode, + LevelNode, + RoofNode, + resolveAutomaticDownspoutLength, + SlabNode, + spatialGridManager, + WallNode, +} from '@pascal-app/core' +import { getRoofTopSurfaceY } from '../shared/roof-surface' +import { + createLeanToAssembly, + isManagedLeanToNode, + isManagedLeanToPost, + leanToCornerPostIndex, + leanToDownspoutLayoutPatch, + leanToGutterLayoutPatch, + leanToPostLayoutPatch, + leanToRoofSegmentLayoutPatch, + managedLeanToPostIndex, + managedLeanToPostSide, + resolveLeanToPostBaseY, + resolveLeanToPostGutterSetback, +} from './assembly' +import { resolveLeanToLayout, resolveLeanToWallPlacement } from './layout' +import { applyLeanToWallAutoSpan } from './roof-attachment' + +beforeEach(() => spatialGridManager.clear()) + +describe('lean-to assembly', () => { + test('composes a standard shed roof, gutter, downspout, and pillar children', () => { + const leanTo = LeanToExtensionNode.parse({ + postLayoutMode: 'count', + postCount: 4, + postWidth: 0.18, + postDepth: 0.14, + span: 4, + projection: 2.5, + lowOverhang: 0.25, + leftOverhang: 0.15, + rightOverhang: 0.15, + }) + const layout = resolveLeanToLayout(leanTo) + const assembly = createLeanToAssembly(leanTo) + + expect(assembly.extension.children).toEqual([ + assembly.roof.id, + ...assembly.posts.map((post) => post.id), + ]) + expect(assembly.roof.type).toBe('roof') + expect(assembly.roof.parentId).toBe(leanTo.id) + expect(assembly.roof.children).toEqual([assembly.segment.id]) + expect(isManagedLeanToNode(assembly.roof, leanTo.id, 'roof')).toBe(true) + expect(assembly.roof.metadata).toMatchObject({ + nodeSelectionProxyId: leanTo.id, + }) + + expect(assembly.segment.type).toBe('roof-segment') + expect(assembly.segment.parentId).toBe(assembly.roof.id) + expect(assembly.segment.roofType).toBe('shed') + expect(assembly.segment.position[0]).toBe(0) + expect(assembly.segment.position[1]).toBeLessThan(layout.lowEdgeHeight) + expect(assembly.segment.depth).toBeCloseTo(layout.roofRun + 0.02, 6) + expect(assembly.segment.overhang).toBe(0) + expect(assembly.segment).toMatchObject({ + shedSideInfillSpan: 4, + shedSideInfillMinX: -2.04, + shedSideInfillMaxX: 2.04, + }) + expect(assembly.segment.metadata).toMatchObject({ + nodeSelectionProxyId: leanTo.id, + }) + expect(assembly.segment.position[2]).toBeCloseTo( + (leanTo.projection + leanTo.lowOverhang - leanTo.highOverhang) / 2 - 0.012, + 6, + ) + expect(assembly.segment.width).toBeCloseTo(4.3) + const roofBounds = getRoofSegmentVisibleTopBounds(assembly.segment) + expect(assembly.segment.position[2] + roofBounds.minZ).toBeCloseTo(-0.02, 6) + expect(assembly.segment.children).toEqual([assembly.gutter.id, assembly.downspout.id]) + expect( + assembly.segment.position[1] + + getRoofTopSurfaceY( + 0, + -assembly.segment.depth / 2 + assembly.segment.trim.back + 0.02, + assembly.segment, + ), + ).toBeCloseTo(leanTo.highEdgeHeight, 5) + + expect(assembly.gutter.type).toBe('gutter') + expect(assembly.gutter.parentId).toBe(assembly.segment.id) + expect(assembly.gutter.roofSegmentId).toBe(assembly.segment.id) + expect(assembly.gutter.profile).toBe('k-style') + expect(assembly.gutter.outlets).toHaveLength(1) + + expect(assembly.downspout.type).toBe('downspout') + expect(assembly.downspout.parentId).toBe(assembly.segment.id) + expect(assembly.downspout.gutterId).toBe(assembly.gutter.id) + expect(assembly.downspout.outletId).toBe(assembly.gutter.outlets[0]?.id) + expect(assembly.downspout.strapStyle).toBe('none') + expect(assembly.downspout.terminal).toBe('straight') + expect(assembly.downspout.lengthMode).toBe('to-ground') + + expect(assembly.posts).toHaveLength(4) + for (const [index, post] of assembly.posts.entries()) { + expect(post.type).toBe('column') + expect(post.parentId).toBe(leanTo.id) + expect(post.position).toEqual([layout.postXs[index], 0, layout.beamZ]) + expect(post.height).toBeCloseTo(layout.postHeight + 0.02, 6) + expect(post.width).toBe(0.18) + expect(post.depth).toBe(0.14) + expect(isManagedLeanToPost(post, leanTo.id)).toBe(true) + } + }) + + test('bends the managed roof-segment, gutter, and posts to follow a curved host', () => { + const leanTo = LeanToExtensionNode.parse({ + span: 6, + projection: 2.5, + highEdgeHeight: 2.8, + postLayoutMode: 'count', + postCount: 3, + spanArcCenterZ: 5, + spanArcRadius: 5, + }) + + const segmentPatch = leanToRoofSegmentLayoutPatch(leanTo) + expect(Number.isFinite(segmentPatch.arc?.radius ?? Number.NaN)).toBe(true) + expect(segmentPatch.arc?.radius).toBeCloseTo(5, 6) + + const assembly = createLeanToAssembly(leanTo) + expect(Number.isFinite(assembly.segment.arc?.radius ?? Number.NaN)).toBe(true) + + const gutterPatch = leanToGutterLayoutPatch(assembly.segment, leanTo, assembly.gutter) + expect(Number.isFinite(gutterPatch.arc?.radius ?? Number.NaN)).toBe(true) + expect(gutterPatch.arc?.radius).toBeCloseTo(assembly.segment.arc?.radius ?? 0, 6) + + // Center post sits on the crown (no yaw); an end post bends off the + // chord and yaws toward the local arc tangent. + const centerPost = leanToPostLayoutPatch(leanTo, 1) + const endPost = leanToPostLayoutPatch(leanTo, 0) + expect(centerPost.rotation).toBeCloseTo(0, 6) + expect(Math.abs(endPost.rotation)).toBeGreaterThan(1e-3) + }) + + test('builds unmodified 3D roof assemblies across a curved-to-tangent-straight join', () => { + const curvedWall = WallNode.parse({ + id: 'wall_curved_3d_continuation', + parentId: 'level_3d_continuation', + start: [0, 0], + end: [6, 0], + curveOffset: 1, + children: ['leanto_curved_3d_continuation'], + }) + const straightWall = WallNode.parse({ + id: 'wall_straight_3d_continuation', + parentId: 'level_3d_continuation', + start: [6, 0], + end: [10.8, 3.6], + children: ['leanto_straight_3d_continuation'], + }) + const curved = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(curvedWall, getWallCurveLength(curvedWall) / 2, 'front')!, + curvedWall, + ), + id: 'leanto_curved_3d_continuation', + } + const straight = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(straightWall, 3, 'front')!, + straightWall, + ), + id: 'leanto_straight_3d_continuation', + } + const nodes = { + [curvedWall.id]: curvedWall, + [straightWall.id]: straightWall, + [curved.id]: curved, + [straight.id]: straight, + } as Record + + const curvedAssembly = createLeanToAssembly(curved, undefined, nodes) + const straightAssembly = createLeanToAssembly(straight, undefined, nodes) + + expect(curvedAssembly.segment.arc?.radius).toBeCloseTo(5, 6) + expect(straightAssembly.segment.arc).toBeUndefined() + expect(straightAssembly.segment.width).toBeCloseTo(resolveLeanToLayout(straight).roofWidth, 6) + expect(straightAssembly.segment.shedFootprintPieces).toBeUndefined() + }) + + test('keeps the roof bend reference on the true wall radius', () => { + const leanTo = LeanToExtensionNode.parse({ + span: 6, + projection: 2.5, + spanArcCenterZ: 4.9, + spanArcRadius: 5, + }) + + const segment = leanToRoofSegmentLayoutPatch(leanTo) + expect(segment.arc?.radius).toBeCloseTo(5, 6) + }) + + test('composes terrain-aware high-side columns for an independent beam', () => { + const leanTo = LeanToExtensionNode.parse({ + highSideMode: 'independent-high-beam', + postLayoutMode: 'count', + postCount: 3, + }) + const assembly = createLeanToAssembly(leanTo) + const highPosts = assembly.posts.filter((post) => managedLeanToPostSide(post) === 'high') + + expect(assembly.posts).toHaveLength(6) + expect(highPosts).toHaveLength(3) + expect(highPosts.every((post) => post.position[2] === 0)).toBe(true) + }) + + test('resolves a managed upper-storey downspout to world ground', () => { + const building = BuildingNode.parse({ id: 'building_test', position: [0, 1, 0] }) + const level = LevelNode.parse({ + id: 'level_upper', + parentId: building.id, + level: 1, + baseElevation: 3, + }) + const wall = WallNode.parse({ + id: 'wall_upper', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const leanTo = LeanToExtensionNode.parse({ parentId: wall.id, position: [2, 0, 0.05] }) + const assembly = createLeanToAssembly(leanTo) + const nodes = Object.fromEntries( + [building, level, wall, assembly.extension, ...assembly.children].map((node) => [ + node.id, + node, + ]), + ) as Record + const outlet = assembly.gutter.outlets[0]! + + expect( + resolveAutomaticDownspoutLength(nodes, assembly.segment, assembly.gutter, outlet.offset), + ).toBeGreaterThan(5) + }) + + test('applies configurable gutter profile, size, and outlet position', () => { + const leanTo = LeanToExtensionNode.parse({ + gutterProfile: 'half-round', + gutterSize: 0.18, + downspoutPosition: -1, + }) + const assembly = createLeanToAssembly(leanTo) + + expect(assembly.gutter.profile).toBe('half-round') + expect(assembly.gutter.size).toBe(0.18) + expect(assembly.gutter.outlets[0]?.offset).toBeLessThan(0) + }) + + test('keeps managed drainage composed but hidden when disabled', () => { + const leanTo = LeanToExtensionNode.parse({ gutterEnabled: false }) + const assembly = createLeanToAssembly(leanTo) + + expect(assembly.gutter.visible).toBe(false) + expect(assembly.gutter.outlets).toEqual([]) + expect(assembly.downspout.visible).toBe(false) + }) + + test('preserves manually adjusted managed drainage', () => { + const leanTo = LeanToExtensionNode.parse({ downspoutPosition: 1 }) + const assembly = createLeanToAssembly(leanTo) + const manualGutter = { + ...assembly.gutter, + outlets: [{ ...assembly.gutter.outlets[0]!, offset: -0.4, generatedBy: undefined }], + } + const manualDownspout = { ...assembly.downspout, length: 1.7, lengthMode: 'manual' as const } + + const gutterPatch = leanToGutterLayoutPatch(assembly.segment, leanTo, manualGutter) + const downspoutPatch = leanToDownspoutLayoutPatch( + assembly.segment, + { ...manualGutter, ...gutterPatch }, + leanTo, + manualDownspout, + ) + + expect(gutterPatch.outlets[0]?.offset).toBe(-0.4) + expect(downspoutPatch.lengthMode).toBe('manual') + }) + + test('matches the connected roof material without changing the host roof', () => { + const leanTo = LeanToExtensionNode.parse({ matchHostRoofMaterial: true }) + const hostRoof = RoofNode.parse({ + materialPreset: 'standing-seam', + topMaterialPreset: 'wood', + edgeMaterialPreset: 'metal', + }) + const originalHost = structuredClone(hostRoof) + + const assembly = createLeanToAssembly(leanTo, hostRoof) + + expect(assembly.roof.materialPreset).toBe(hostRoof.materialPreset) + expect(assembly.roof.topMaterialPreset).toBe(hostRoof.topMaterialPreset) + expect(assembly.roof.edgeMaterialPreset).toBe(hostRoof.edgeMaterialPreset) + expect(hostRoof).toEqual(originalHost) + }) + + test('places the connected roof cut on the wall so its sloped side edges reach it', () => { + const leanTo = LeanToExtensionNode.parse({ projection: 2.5, connectionInset: 0.3 }) + + const assembly = createLeanToAssembly(leanTo) + const bounds = getRoofSegmentVisibleTopBounds(assembly.segment) + + expect(assembly.segment.trim.back).toBeCloseTo(0.002, 6) + expect(assembly.segment.position[2] + bounds.minZ).toBeCloseTo(-0.02, 6) + }) + + test('automatically fills perpendicular lean-to roof corners', () => { + const wallA = WallNode.parse({ + id: 'wall_a', + parentId: 'level_test', + start: [0, 0], + end: [4, 0], + }) + const wallB = WallNode.parse({ + id: 'wall_b', + parentId: 'level_test', + start: [4, 0], + end: [4, -4], + }) + const leanToA = LeanToExtensionNode.parse({ + id: 'leanto_a', + parentId: wallA.id, + position: [2, 0, 0.05], + span: 4, + leftOverhang: 0, + rightOverhang: 0, + }) + const leanToB = LeanToExtensionNode.parse({ + id: 'leanto_b', + parentId: wallB.id, + position: [2, 0, 0.05], + span: 4, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wallA.id]: wallA, + [wallB.id]: wallB, + [leanToA.id]: leanToA, + [leanToB.id]: leanToB, + } as Record + + const assembly = createLeanToAssembly(leanToA, undefined, nodes) + const neighborAssembly = createLeanToAssembly(leanToB, undefined, nodes) + const layout = resolveLeanToLayout(leanToA) + const neighborLayout = resolveLeanToLayout(leanToB) + + const pointInLevel = ( + point: readonly [number, number], + extension: typeof leanToA, + host: typeof wallA, + ): readonly [number, number] => { + const leanCos = Math.cos(extension.rotation[1]) + const leanSin = Math.sin(extension.rotation[1]) + const wallAngle = Math.atan2(host.end[1] - host.start[1], host.end[0] - host.start[0]) + const wallCos = Math.cos(wallAngle) + const wallSin = Math.sin(wallAngle) + const wallX = extension.position[0] + point[0] * leanCos + point[1] * leanSin + const wallZ = extension.position[2] - point[0] * leanSin + point[1] * leanCos + return [ + host.start[0] + wallX * wallCos - wallZ * wallSin, + host.start[1] + wallX * wallSin + wallZ * wallCos, + ] + } + const gutterEnd = ( + gutter: typeof assembly.gutter, + segment: typeof assembly.segment, + extension: typeof leanToA, + host: typeof wallA, + side: 'left' | 'right', + ) => { + const sign = side === 'left' ? -1 : 1 + const localX = gutter.position[0] + ((Math.cos(gutter.rotation) * gutter.length) / 2) * sign + const localZ = gutter.position[2] - ((Math.sin(gutter.rotation) * gutter.length) / 2) * sign + return pointInLevel( + [segment.position[0] + localX, segment.position[2] + localZ], + extension, + host, + ) + } + + expect(assembly.segment.width).toBeGreaterThan(layout.roofWidth) + expect(assembly.segment.position[0]).toBeGreaterThan(layout.roofCenterX) + expect(assembly.segment.trim.frontRightX).toBe(0) + expect(assembly.segment.trim.frontRightZ).toBe(0) + expect(assembly.segment.trim.backLeftX).toBe(0) + expect(assembly.segment.trim.backLeftZ).toBe(0) + expect(assembly.segment.trim.backRightX).toBe(0) + expect(assembly.segment.trim.backRightZ).toBe(0) + expect(assembly.segment.shedOpenEndSides).toEqual(['right']) + expect(assembly.segment.shedFootprintPieces).toHaveLength(2) + expect( + assembly.posts.some( + (post) => managedLeanToPostIndex(post) === leanToCornerPostIndex('right'), + ), + ).toBe(true) + expect(assembly.gutter.length).toBeCloseTo(assembly.segment.width, 6) + expect(assembly.gutter.position[0]).toBeCloseTo(0, 6) + expect(assembly.gutter.metadata).toMatchObject({ + leanToGutterMitres: { left: 0, right: Math.PI / 4 }, + }) + expect(neighborAssembly.gutter.metadata).toMatchObject({ + leanToGutterMitres: { left: Math.PI / 4, right: 0 }, + }) + + const gutterA = gutterEnd(assembly.gutter, assembly.segment, leanToA, wallA, 'right') + const gutterB = gutterEnd( + neighborAssembly.gutter, + neighborAssembly.segment, + leanToB, + wallB, + 'left', + ) + expect(gutterA[0]).toBeCloseTo(gutterB[0], 6) + expect(gutterA[1]).toBeCloseTo(gutterB[1], 6) + + const cornerPost = assembly.posts.find( + (post) => managedLeanToPostIndex(post) === leanToCornerPostIndex('right'), + )! + const postFromA = pointInLevel([cornerPost.position[0], cornerPost.position[2]], leanToA, wallA) + const postFromB = pointInLevel( + [-neighborLayout.span / 2 - leanToA.position[2] - layout.beamZ, neighborLayout.beamZ], + leanToB, + wallB, + ) + expect(postFromA[0]).toBeCloseTo(postFromB[0], 6) + expect(postFromA[1]).toBeCloseTo(postFromB[1], 6) + }) + + test('keeps perpendicular lean-to roof corners square when auto miter is disabled', () => { + const wallA = WallNode.parse({ + id: 'wall_a_disabled', + parentId: 'level_test', + start: [0, 0], + end: [4, 0], + }) + const wallB = WallNode.parse({ + id: 'wall_b_disabled', + parentId: 'level_test', + start: [4, 0], + end: [4, 4], + }) + const leanToA = LeanToExtensionNode.parse({ + id: 'leanto_a_disabled', + parentId: wallA.id, + position: [2, 0, 0.05], + span: 4, + autoMiterCorners: false, + leftOverhang: 0, + rightOverhang: 0, + }) + const leanToB = LeanToExtensionNode.parse({ + id: 'leanto_b_disabled', + parentId: wallB.id, + position: [2, 0, 0.05], + span: 4, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wallA.id]: wallA, + [wallB.id]: wallB, + [leanToA.id]: leanToA, + [leanToB.id]: leanToB, + } as Record + + const assembly = createLeanToAssembly(leanToA, undefined, nodes) + + expect(assembly.segment.width).toBeCloseTo(resolveLeanToLayout(leanToA).roofWidth, 6) + expect(assembly.segment.trim.backRightX).toBe(0) + expect(assembly.segment.trim.backRightZ).toBe(0) + expect( + assembly.posts.some( + (post) => managedLeanToPostIndex(post) === leanToCornerPostIndex('right'), + ), + ).toBe(false) + }) + + test('does not connect perpendicular lean-to roof corners across levels', () => { + const wallA = WallNode.parse({ + id: 'wall_a_level', + parentId: 'level_ground', + start: [0, 0], + end: [4, 0], + }) + const wallB = WallNode.parse({ + id: 'wall_b_level', + parentId: 'level_upper', + start: [4, 0], + end: [4, 4], + }) + const leanToA = LeanToExtensionNode.parse({ + id: 'leanto_a_level', + parentId: wallA.id, + position: [2, 0, 0.05], + span: 4, + leftOverhang: 0, + rightOverhang: 0, + }) + const leanToB = LeanToExtensionNode.parse({ + id: 'leanto_b_level', + parentId: wallB.id, + position: [2, 0, 0.05], + span: 4, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wallA.id]: wallA, + [wallB.id]: wallB, + [leanToA.id]: leanToA, + [leanToB.id]: leanToB, + } as Record + + const assembly = createLeanToAssembly(leanToA, undefined, nodes) + + expect(assembly.segment.width).toBeCloseTo(resolveLeanToLayout(leanToA).roofWidth, 6) + expect(assembly.segment.trim.backRightX).toBe(0) + expect( + assembly.posts.some( + (post) => managedLeanToPostIndex(post) === leanToCornerPostIndex('right'), + ), + ).toBe(false) + }) + + test('keeps the triangular side edge recessed beneath the sloping eave', () => { + const leanTo = LeanToExtensionNode.parse({ projection: 2.5, lowOverhang: 0.25 }) + const layout = resolveLeanToLayout(leanTo) + + const { segment } = createLeanToAssembly(leanTo) + const triangleFrontZ = segment.position[2] + segment.depth / 2 + + const roofBounds = getRoofSegmentVisibleTopBounds(segment) + expect(triangleFrontZ).toBeCloseTo(layout.projection + leanTo.lowOverhang - 0.002, 6) + expect(segment.position[2] + roofBounds.maxZ).toBeGreaterThan(triangleFrontZ) + }) + + test('extends managed pillars down from a slab-supported wall to exterior ground', () => { + const levelId = 'level_test' + const slab = SlabNode.parse({ + id: 'slab_test', + parentId: levelId, + polygon: [ + [-3, -1], + [3, -1], + [3, 0.2], + [-3, 0.2], + ], + elevation: 0.2, + }) + const wall = WallNode.parse({ + id: 'wall_test', + parentId: levelId, + start: [-2, 0], + end: [2, 0], + thickness: 0.1, + supportSlabId: slab.id, + }) + const leanTo = LeanToExtensionNode.parse({ + parentId: wall.id, + position: [2, 0, wall.thickness / 2], + projection: 2.5, + }) + const level = { + id: levelId, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [slab.id, wall.id], + level: 0, + height: 2.5, + baseElevation: 0, + } as AnyNode + const nodes = { + [level.id]: level, + [slab.id]: slab, + [wall.id]: wall, + [leanTo.id]: leanTo, + } + spatialGridManager.handleNodeCreated(slab, levelId) + + const baseY = resolveLeanToPostBaseY(leanTo, wall, nodes, 0) + const post = leanToPostLayoutPatch(leanTo, 0, baseY) + + expect(post.position[1]).toBeCloseTo(-0.22, 6) + expect(post.position[1] + post.height).toBeCloseTo( + resolveLeanToLayout(leanTo).postHeight + 0.02, + 6, + ) + }) + + test('keeps a swapped pillar beneath the beam while its shaft clears the gutter', () => { + const leanTo = LeanToExtensionNode.parse({ lowOverhang: 0.25, projection: 2.5 }) + const swapped = { + ...createLeanToAssembly(leanTo).posts[0]!, + capitalStyle: 'wood-bracket' as const, + capitalHeight: 0.3, + capitalWidthScale: 2, + bracketDepth: 0.5, + } + + const setback = resolveLeanToPostGutterSetback(leanTo, swapped) + const post = leanToPostLayoutPatch(leanTo, 0, 0, setback) + expect(setback).toBeGreaterThan(0) + expect(post.position[1] + post.height).toBeGreaterThan(resolveLeanToLayout(leanTo).postHeight) + expect(post.position[2]).toBeGreaterThanOrEqual(leanTo.projection - leanTo.beamWidth / 2) + expect(post.position[2] + swapped.depth / 2 + 0.02).toBeLessThanOrEqual( + leanTo.projection + leanTo.lowOverhang + 1e-6, + ) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/assembly.ts b/packages/nodes/src/lean-to-extension/assembly.ts new file mode 100644 index 0000000000..674befd216 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/assembly.ts @@ -0,0 +1,734 @@ +import { + type AnyNode, + COLUMN_PRESETS, + ColumnNode, + type ColumnNode as ColumnNodeType, + DownspoutNode, + type DownspoutNode as DownspoutNodeType, + GutterNode, + type GutterNode as GutterNodeType, + generateId, + getWallBaseElevationForNodes, + type LeanToExtensionNode, + levelBaseElevationAt, + RoofNode, + type RoofNode as RoofNodeType, + RoofSegmentNode, + type RoofSegmentNode as RoofSegmentNodeType, + spatialGridManager, + type WallNode, +} from '@pascal-app/core' +import { resolveEaveSnap } from '../gutter/eave-snap' +import { getRoofTopSurfaceY } from '../shared/roof-surface' +import { bendLocalPoint, bendRotationYAtLocalX, isCurvedLeanTo } from './arc' +import { + applyLeanToCornerRoofPieces, + LEAN_TO_CORNER_JOINTS_KEY, + type LeanToCornerJoint, + type LeanToCornerSide, + leanToCornerJointMetadata, + resolveLeanToCornerJoints, +} from './corner-joint' +import { resolveLeanToLayout } from './layout' + +const MANAGED_BY_KEY = 'managedByLeanTo' +const MANAGED_ROLE_KEY = 'leanToRole' +const SELECTION_PROXY_KEY = 'nodeSelectionProxyId' +const GUTTER_MITRES_KEY = 'leanToGutterMitres' +const GUTTER_EAVE_Y_KEY = 'leanToGutterEaveY' +const GUTTER_ARC_STRAIGHT_ENDS_KEY = 'leanToGutterArcStraightEnds' +const POST_INDEX_KEY = 'leanToPostIndex' +const POST_SIDE_KEY = 'leanToPostSide' +const POST_GUTTER_CLEARANCE = 0.02 +const POST_GROUND_EMBED = 0.02 +const POST_BEAM_EMBED = 0.02 +const WALL_CONNECTION_TRIM = 0.002 +const WALL_CONNECTION_OVERLAP = 0.02 +export const LEFT_CORNER_POST_INDEX = -1001 +export const RIGHT_CORNER_POST_INDEX = -1002 + +export function leanToCornerPostIndex(side: LeanToCornerSide): number { + return side === 'left' ? LEFT_CORNER_POST_INDEX : RIGHT_CORNER_POST_INDEX +} + +type LeanToManagedRole = 'roof' | 'roof-segment' | 'gutter' | 'downspout' | 'post' +export type LeanToPostSide = 'high' | 'low' + +export type LeanToRoofMaterialPatch = Pick< + RoofNodeType, + | 'material' + | 'materialPreset' + | 'topMaterial' + | 'topMaterialPreset' + | 'edgeMaterial' + | 'edgeMaterialPreset' + | 'wallMaterial' + | 'wallMaterialPreset' +> + +function metadataRecord(metadata: unknown): Record { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +function managedMetadata( + leanTo: LeanToExtensionNode, + role: LeanToManagedRole, + extra: Record = {}, +) { + return { + [MANAGED_BY_KEY]: leanTo.id, + [MANAGED_ROLE_KEY]: role, + ...(role === 'roof' || role === 'roof-segment' ? { [SELECTION_PROXY_KEY]: leanTo.id } : {}), + ...extra, + } +} + +export function isManagedLeanToNode( + node: AnyNode, + leanToId: LeanToExtensionNode['id'], + role?: LeanToManagedRole, +): boolean { + const metadata = metadataRecord(node.metadata) + return ( + metadata[MANAGED_BY_KEY] === leanToId && + (role === undefined || metadata[MANAGED_ROLE_KEY] === role) + ) +} + +export function isManagedLeanToPost( + column: ColumnNodeType, + leanToId: LeanToExtensionNode['id'], +): boolean { + return isManagedLeanToNode(column, leanToId, 'post') +} + +export function managedLeanToPostIndex(column: ColumnNodeType): number | null { + const index = metadataRecord(column.metadata)[POST_INDEX_KEY] + return typeof index === 'number' && Number.isInteger(index) ? index : null +} + +export function managedLeanToPostSide(column: ColumnNodeType): LeanToPostSide { + return metadataRecord(column.metadata)[POST_SIDE_KEY] === 'high' ? 'high' : 'low' +} + +export type LeanToPostLayoutPatch = Pick< + ColumnNodeType, + | 'position' + | 'rotation' + | 'height' + | 'width' + | 'depth' + | 'crossSection' + | 'baseStyle' + | 'baseHeight' + | 'baseWidthScale' + | 'baseDepthScale' + | 'slots' +> + +export function leanToPostLayoutPatch( + leanTo: LeanToExtensionNode, + index: number, + baseY = 0, + gutterSetback = 0, + side: LeanToPostSide = 'low', +): LeanToPostLayoutPatch { + const layout = resolveLeanToLayout(leanTo) + const baseStyle = + leanTo.footingStyle === 'concrete-pad' + ? ('square-plinth' as const) + : leanTo.footingStyle === 'base-plate' + ? ('simple-square' as const) + : ('none' as const) + const postX = layout.postXs[index] ?? 0 + const postZ = side === 'high' ? 0 : layout.beamZ - gutterSetback + const bent = bendLocalPoint(leanTo, postX, postZ) + return { + position: [bent.x, baseY, bent.y], + rotation: bendRotationYAtLocalX(leanTo, postX), + height: Math.max( + 0.2, + (side === 'high' + ? layout.highEdgeHeight - + leanTo.roofThickness / 2 - + leanTo.ledgerHeight + + leanTo.ledgerVerticalOffset + : layout.postHeight) - + baseY + + POST_BEAM_EMBED, + ), + width: leanTo.postWidth, + depth: leanTo.postDepth, + crossSection: 'rectangular', + baseStyle, + baseHeight: + leanTo.footingStyle === 'concrete-pad' + ? 0.12 + : leanTo.footingStyle === 'base-plate' + ? 0.04 + : 0, + baseWidthScale: leanTo.footingStyle === 'concrete-pad' ? 2 : 1.4, + baseDepthScale: leanTo.footingStyle === 'concrete-pad' ? 2 : 1.4, + slots: { + shaft: leanTo.slots?.posts ?? 'library:concrete-plaster', + ...(leanTo.footingStyle === 'none' + ? {} + : { base: leanTo.slots?.footings ?? 'library:concrete-plaster' }), + }, + } +} + +export function leanToCornerPostLayoutPatch( + leanTo: LeanToExtensionNode, + joint: LeanToCornerJoint, + baseY = 0, + gutterSetback = 0, +): LeanToPostLayoutPatch { + const cornerX = joint.sharedPostPosition[0] + const bent = bendLocalPoint(leanTo, cornerX, joint.sharedPostPosition[2] - gutterSetback) + return { + ...leanToPostLayoutPatch(leanTo, 0, baseY, gutterSetback, 'low'), + position: [bent.x, baseY, bent.y], + rotation: bendRotationYAtLocalX(leanTo, cornerX), + } +} + +export function resolveLeanToPostGutterSetback( + leanTo: LeanToExtensionNode, + column?: ColumnNodeType, +): number { + if (!column) return 0 + const shaftHalfDepth = column.depth / 2 + const baseHalfDepth = + column.baseStyle === 'none' ? 0 : (column.depth * Math.max(1, column.baseDepthScale ?? 1)) / 2 + const isBracketCapital = + column.capitalStyle === 'south-indian-bracket' || column.capitalStyle === 'wood-bracket' + const capitalFullDepth = isBracketCapital + ? column.depth * (Math.max(1, column.capitalWidthScale ?? 1.6) + 0.32) + + (column.bracketDepth ?? 0.35) + : column.depth * Math.max(1, column.capitalDepthScale ?? column.capitalWidthScale ?? 1) + const capitalHalfDepth = column.capitalStyle === 'none' ? 0 : capitalFullDepth / 2 + const frameHalfDepth = + column.supportStyle === 'vertical' + ? 0 + : (Math.max(column.braceDepth ?? column.depth, 0.04) * 1.75) / 2 + const outwardHalfDepth = Math.max(shaftHalfDepth, baseHalfDepth, capitalHalfDepth, frameHalfDepth) + const gutterClearanceSetback = Math.max( + 0, + outwardHalfDepth + POST_GUTTER_CLEARANCE - Math.max(0, leanTo.lowOverhang), + ) + return Math.min(gutterClearanceSetback, leanTo.beamWidth / 2) +} + +export function resolveLeanToPostBaseY( + leanTo: LeanToExtensionNode, + wall: WallNode, + nodes: Record, + index: number, + side: LeanToPostSide = 'low', +): number { + const layout = resolveLeanToLayout(leanTo) + const postX = layout.postXs[index] ?? 0 + const postZ = side === 'high' ? 0 : layout.beamZ + const bent = bendLocalPoint(leanTo, postX, postZ) + return resolveLeanToPostBaseYAtLocalPosition(leanTo, wall, nodes, [bent.x, 0, bent.y]) +} + +export function resolveLeanToPostBaseYAtLocalPosition( + leanTo: LeanToExtensionNode, + wall: WallNode, + nodes: Record, + localPosition: readonly [number, number, number], +): number { + const levelId = wall.parentId + if (!levelId || nodes[levelId]?.type !== 'level') return 0 + + const postX = localPosition[0] + const leanRotation = leanTo.rotation[1] + const leanCos = Math.cos(leanRotation) + const leanSin = Math.sin(leanRotation) + const postZ = localPosition[2] + const wallLocalX = leanTo.position[0] + postX * leanCos + postZ * leanSin + const wallLocalZ = leanTo.position[2] - postX * leanSin + postZ * leanCos + const wallAngle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const wallCos = Math.cos(wallAngle) + const wallSin = Math.sin(wallAngle) + const position: [number, number, number] = [ + wall.start[0] + wallLocalX * wallCos - wallLocalZ * wallSin, + 0, + wall.start[1] + wallLocalX * wallSin + wallLocalZ * wallCos, + ] + const support = spatialGridManager.getSlabSupportForItem( + levelId, + position, + [leanTo.postWidth, 1, leanTo.postDepth], + [0, -wallAngle + leanRotation, 0], + ) + const groundY = + support.slabId === null + ? levelBaseElevationAt(nodes, levelId, position[0], position[2]) + : support.elevation + return ( + groundY - getWallBaseElevationForNodes(wall, nodes) - leanTo.position[1] - POST_GROUND_EMBED + ) +} + +export function createManagedLeanToPost( + leanTo: LeanToExtensionNode, + index: number, + side: LeanToPostSide = 'low', +): ColumnNodeType { + const { label: _label, ...preset } = COLUMN_PRESETS.squarePillar + return ColumnNode.parse({ + ...preset, + ...leanToPostLayoutPatch(leanTo, index, 0, 0, side), + name: `Lean-to ${side === 'high' ? 'High ' : ''}Post ${index + 1}`, + parentId: leanTo.id, + style: 'plain', + edgeSoftness: 0.008, + capitalHeight: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + shaftStartScale: 1, + shaftEndScale: 1, + metadata: managedMetadata(leanTo, 'post', { + [POST_INDEX_KEY]: index, + [POST_SIDE_KEY]: side, + }), + }) +} + +export function createManagedLeanToCornerPost( + leanTo: LeanToExtensionNode, + joint: LeanToCornerJoint, +): ColumnNodeType { + const { label: _label, ...preset } = COLUMN_PRESETS.squarePillar + return ColumnNode.parse({ + ...preset, + ...leanToCornerPostLayoutPatch(leanTo, joint), + name: `Lean-to ${joint.side === 'left' ? 'Left' : 'Right'} Corner Post`, + parentId: leanTo.id, + style: 'plain', + edgeSoftness: 0.008, + capitalHeight: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + shaftStartScale: 1, + shaftEndScale: 1, + metadata: managedMetadata(leanTo, 'post', { + [POST_INDEX_KEY]: leanToCornerPostIndex(joint.side), + [POST_SIDE_KEY]: 'low', + }), + }) +} + +export function resolveLeanToPostIndexes( + leanTo: LeanToExtensionNode, + cornerJoints: Partial>, + side: LeanToPostSide, +): number[] { + const layout = resolveLeanToLayout(leanTo) + return Array.from({ length: layout.postXs.length }, (_, index) => index).filter((index) => { + if (side === 'high') return true + const x = layout.postXs[index] ?? 0 + const left = cornerJoints.left + if (left?.kind === 'concave' && x <= left.sharedPostPosition[0] + 1e-6) return false + const right = cornerJoints.right + if (right?.kind === 'concave' && x >= right.sharedPostPosition[0] - 1e-6) return false + return true + }) +} + +export type LeanToRoofSegmentLayoutPatch = Pick< + RoofSegmentNodeType, + | 'position' + | 'rotation' + | 'roofType' + | 'width' + | 'depth' + | 'wallHeight' + | 'pitch' + | 'wallThickness' + | 'deckThickness' + | 'shingleThickness' + | 'overhang' + | 'arc' + | 'shedSideInfillSpan' + | 'shedSideInfillMinX' + | 'shedSideInfillMaxX' + | 'shedFootprintPieces' + | 'shedOpenEndSides' + | 'trim' + | 'metadata' +> + +export function leanToRoofSegmentLayoutPatch( + leanTo: LeanToExtensionNode, + nodes?: Record, +): LeanToRoofSegmentLayoutPatch { + const layout = resolveLeanToLayout(leanTo) + const wall = + leanTo.parentId && nodes?.[leanTo.parentId]?.type === 'wall' + ? (nodes[leanTo.parentId] as WallNode) + : undefined + const shingleThickness = leanTo.shingleThickness ?? 0.025 + const overhang = 0 + const depth = layout.roofRun + WALL_CONNECTION_OVERLAP + const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) + const leftCornerExtension = cornerJoints.left?.roofExtension ?? 0 + const rightCornerExtension = cornerJoints.right?.roofExtension ?? 0 + const width = Math.max(0.05, layout.roofWidth + leftCornerExtension + rightCornerExtension) + const roofCenterX = layout.roofCenterX + (rightCornerExtension - leftCornerExtension) / 2 + const roofCenterZ = + depth / 2 - Math.max(0, leanTo.highOverhang) - WALL_CONNECTION_TRIM - WALL_CONNECTION_OVERLAP + // Concentric-band descriptor in segment-local coords. The whole lean-to bends + // about the wall's true arc center at lean-to-local (0, spanArcCenterZ); the + // segment is offset by (roofCenterX, roofCenterZ), so the center lands here. + // `radius` is the signed bend reference |spanArcCenterZ| (its sign follows + // centerZ), which reproduces the members' bend transform exactly. + const arc = isCurvedLeanTo(leanTo) + ? { + centerX: -roofCenterX, + centerZ: (leanTo.spanArcCenterZ ?? 0) - roofCenterZ, + radius: leanTo.spanArcRadius ?? 0, + } + : undefined + const roofBack = roofCenterZ - depth / 2 + (leanTo.highOverhang > 0 ? 0 : WALL_CONNECTION_TRIM) + const roofFront = roofCenterZ + depth / 2 + const roofPieces: [number, number][][] = applyLeanToCornerRoofPieces( + [ + [layout.roofCenterX - layout.roofWidth / 2, roofBack], + [layout.roofCenterX + layout.roofWidth / 2, roofBack], + [layout.roofCenterX + layout.roofWidth / 2, roofFront], + [layout.roofCenterX - layout.roofWidth / 2, roofFront], + ], + cornerJoints, + ).map((polygon) => + polygon.map(([x = 0, z = 0]) => [x - roofCenterX, z - roofCenterZ] as [number, number]), + ) + const jointSides = Object.values(cornerJoints).flatMap((joint) => (joint ? [joint.side] : [])) + const sideMemberFaceInset = Math.min( + Math.max(0, leanTo.rafterWidth / 2), + Math.max(0, layout.span / 2 - 0.01), + ) + const surfaceProbe = { + roofType: 'shed', + width, + depth, + wallHeight: 0, + pitch: layout.effectivePitchDegrees, + wallThickness: 0.01, + deckThickness: leanTo.roofThickness, + overhang, + shingleThickness, + } as RoofSegmentNodeType + const topAtWall = getRoofTopSurfaceY( + 0, + -depth / 2 + Math.max(0, leanTo.highOverhang) + WALL_CONNECTION_TRIM + WALL_CONNECTION_OVERLAP, + surfaceProbe, + ) + return { + position: [roofCenterX, layout.highEdgeHeight - topAtWall, roofCenterZ], + rotation: 0, + roofType: 'shed', + width, + depth, + wallHeight: 0, + pitch: layout.effectivePitchDegrees, + wallThickness: 0.01, + deckThickness: leanTo.roofThickness, + shingleThickness, + overhang, + arc, + shedSideInfillSpan: layout.span, + shedSideInfillMinX: -layout.span / 2 - sideMemberFaceInset - roofCenterX, + shedSideInfillMaxX: layout.span / 2 + sideMemberFaceInset - roofCenterX, + shedFootprintPieces: jointSides.length > 0 ? roofPieces : undefined, + shedOpenEndSides: jointSides.length > 0 ? jointSides : undefined, + metadata: managedMetadata(leanTo, 'roof-segment'), + trim: { + left: 0, + right: 0, + front: 0, + back: leanTo.highOverhang > 0 ? 0 : WALL_CONNECTION_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, + }, + } +} + +export function leanToGutterLayoutPatch( + segment: RoofSegmentNodeType, + leanTo: LeanToExtensionNode, + gutter?: GutterNodeType, + nodes?: Record, +): Pick< + GutterNodeType, + | 'position' + | 'rotation' + | 'length' + | 'arc' + | 'roofSegmentId' + | 'visible' + | 'profile' + | 'size' + | 'outlets' + | 'metadata' +> { + const snap = resolveEaveSnap(segment, 0, segment.depth / 2) + const existingOutlet = gutter?.outlets[0] + const outletId = existingOutlet?.id ?? generateId('outlet') + const wall = + leanTo.parentId && nodes?.[leanTo.parentId]?.type === 'wall' + ? (nodes[leanTo.parentId] as WallNode) + : undefined + const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) + const ownWorldEaveY = + (wall && nodes ? getWallBaseElevationForNodes(wall, nodes) : 0) + + leanTo.position[1] + + segment.position[1] + + snap.eaveY + let sharedWorldEaveY = ownWorldEaveY + if (leanTo.gutterEnabled && nodes) { + for (const joint of Object.values(cornerJoints)) { + const neighbor = joint ? nodes[joint.neighborId] : undefined + if (neighbor?.type !== 'lean-to-extension' || !neighbor.gutterEnabled) continue + const neighborWall = neighbor.parentId ? nodes[neighbor.parentId] : undefined + if (neighborWall?.type !== 'wall') continue + const neighborSegment = leanToRoofSegmentLayoutPatch(neighbor, nodes) + const neighborSnap = resolveEaveSnap( + neighborSegment as RoofSegmentNodeType, + 0, + neighborSegment.depth / 2, + ) + sharedWorldEaveY = Math.max( + sharedWorldEaveY, + getWallBaseElevationForNodes(neighborWall, nodes) + + neighbor.position[1] + + neighborSegment.position[1] + + neighborSnap.eaveY, + ) + } + } + const sharedLocalEaveY = sharedWorldEaveY - ownWorldEaveY + snap.eaveY + const gutterMitreForJoint = (joint: LeanToCornerJoint | undefined): number => { + if (!(leanTo.gutterEnabled && joint && nodes)) return 0 + const neighbor = nodes[joint.neighborId] + return neighbor?.type === 'lean-to-extension' && neighbor.gutterEnabled ? joint.gutterMitre : 0 + } + const length = Math.max(0.05, segment.width + 2 * segment.overhang) + const jointAwareDownspoutPosition = + cornerJoints.left && leanTo.downspoutPosition < -0.75 + ? cornerJoints.right + ? 0 + : 1 + : cornerJoints.right && leanTo.downspoutPosition > 0.75 + ? cornerJoints.left + ? 0 + : -1 + : leanTo.downspoutPosition + const offset = jointAwareDownspoutPosition * Math.max(0, length / 2 - 0.16) + // The eave follows the same concentric arc as the deck. The eave snap is a pure + // translation of segment-local (rotation 0 for shed's +Z eave), so the segment + // arc center maps to gutter-mesh-local by subtracting the snap seat; radius (the + // signed bend reference) is unchanged. + const gutterArc = segment.arc + ? { + centerX: segment.arc.centerX - snap.eaveX, + centerZ: segment.arc.centerZ - snap.eaveZ, + radius: segment.arc.radius, + } + : undefined + const layout = resolveLeanToLayout(leanTo) + const arcStraightEnds = gutterArc + ? Object.fromEntries( + (['left', 'right'] as const).flatMap((side) => { + if (!cornerJoints[side]) return [] + const sign = side === 'left' ? -1 : 1 + const startX = + layout.roofCenterX + sign * (layout.roofWidth / 2) - segment.position[0] - snap.eaveX + return [[side, { startX, endX: sign * (length / 2) }]] + }), + ) + : undefined + const outlet = + existingOutlet && existingOutlet.generatedBy !== 'default-downspout' + ? existingOutlet + : { + id: outletId, + offset, + diameter: existingOutlet?.diameter ?? 0.07, + generatedBy: 'default-downspout' as const, + } + return { + position: [snap.eaveX, snap.eaveY, snap.eaveZ], + rotation: snap.rotation, + length, + arc: gutterArc, + roofSegmentId: segment.id, + visible: leanTo.gutterEnabled, + profile: leanTo.gutterProfile, + size: leanTo.gutterSize, + outlets: leanTo.gutterEnabled && leanTo.downspoutEnabled ? [outlet] : [], + metadata: { + ...metadataRecord(gutter?.metadata), + ...managedMetadata(leanTo, 'gutter', { + [GUTTER_MITRES_KEY]: { + left: gutterMitreForJoint(cornerJoints.left), + right: gutterMitreForJoint(cornerJoints.right), + }, + [GUTTER_EAVE_Y_KEY]: sharedLocalEaveY, + ...(arcStraightEnds && Object.keys(arcStraightEnds).length > 0 + ? { [GUTTER_ARC_STRAIGHT_ENDS_KEY]: arcStraightEnds } + : {}), + }), + }, + } +} + +export function leanToDownspoutLayoutPatch( + _segment: RoofSegmentNodeType, + gutter: GutterNodeType, + leanTo: LeanToExtensionNode, + downspout?: DownspoutNodeType, +): Pick { + const outlet = gutter.outlets[0] + return { + diameter: outlet?.diameter ?? 0.07, + gutterId: gutter.id, + lengthMode: downspout?.lengthMode === 'manual' ? 'manual' : 'to-ground', + visible: leanTo.gutterEnabled && leanTo.downspoutEnabled, + outletId: outlet?.id, + } +} + +export function leanToRoofMaterialPatch(hostRoof: RoofNodeType): LeanToRoofMaterialPatch { + return { + material: hostRoof.material, + materialPreset: hostRoof.materialPreset, + topMaterial: hostRoof.topMaterial, + topMaterialPreset: hostRoof.topMaterialPreset, + edgeMaterial: hostRoof.edgeMaterial, + edgeMaterialPreset: hostRoof.edgeMaterialPreset, + wallMaterial: hostRoof.wallMaterial, + wallMaterialPreset: hostRoof.wallMaterialPreset, + } +} + +export type LeanToRoofAssembly = { + roof: RoofNodeType + segment: RoofSegmentNodeType + gutter: GutterNodeType + downspout: DownspoutNodeType +} + +export function createManagedLeanToRoofAssembly( + leanTo: LeanToExtensionNode, + hostRoof?: RoofNodeType, + nodes?: Record, +): LeanToRoofAssembly { + const roof = RoofNode.parse({ + ...(hostRoof && leanTo.matchHostRoofMaterial !== false + ? leanToRoofMaterialPatch(hostRoof) + : {}), + name: 'Lean-to Roof', + parentId: leanTo.id, + position: [0, 0, 0], + rotation: 0, + metadata: managedMetadata(leanTo, 'roof'), + }) + const segment = RoofSegmentNode.parse({ + ...leanToRoofSegmentLayoutPatch(leanTo, nodes), + name: 'Lean-to Shed Roof', + parentId: roof.id, + }) + const gutter = GutterNode.parse({ + ...leanToGutterLayoutPatch(segment, leanTo, undefined, nodes), + name: 'Lean-to Gutter', + parentId: segment.id, + }) + const downspout = DownspoutNode.parse({ + ...leanToDownspoutLayoutPatch(segment, gutter, leanTo), + name: 'Lean-to Downspout', + parentId: segment.id, + lengthMode: 'to-ground', + strapStyle: 'none', + terminal: 'straight', + metadata: managedMetadata(leanTo, 'downspout'), + }) + + return { + roof: { ...roof, children: [segment.id] }, + segment: { ...segment, children: [gutter.id, downspout.id] }, + gutter, + downspout, + } +} + +export function createLeanToAssembly( + leanTo: LeanToExtensionNode, + hostRoof?: RoofNodeType, + nodes?: Record, +): { + extension: LeanToExtensionNode + roof: RoofNodeType + segment: RoofSegmentNodeType + gutter: GutterNodeType + downspout: DownspoutNodeType + posts: ColumnNodeType[] + children: AnyNode[] +} { + const roofAssembly = createManagedLeanToRoofAssembly(leanTo, hostRoof, nodes) + const wall = + leanTo.parentId && nodes?.[leanTo.parentId]?.type === 'wall' + ? (nodes[leanTo.parentId] as WallNode) + : undefined + const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) + const posts = resolveLeanToPostIndexes(leanTo, cornerJoints, 'low').map((index) => + createManagedLeanToPost(leanTo, index, 'low'), + ) + for (const joint of Object.values(cornerJoints)) { + if (joint?.sharedPostOwner) posts.push(createManagedLeanToCornerPost(leanTo, joint)) + } + if (leanTo.highSideMode === 'independent-high-beam') { + posts.push( + ...resolveLeanToPostIndexes(leanTo, cornerJoints, 'high').map((index) => + createManagedLeanToPost(leanTo, index, 'high'), + ), + ) + } + const children: AnyNode[] = [ + roofAssembly.roof, + roofAssembly.segment, + roofAssembly.gutter, + roofAssembly.downspout, + ...posts, + ] + return { + extension: { + ...leanTo, + metadata: { + ...metadataRecord(leanTo.metadata), + [LEAN_TO_CORNER_JOINTS_KEY]: leanToCornerJointMetadata(cornerJoints), + }, + children: [roofAssembly.roof.id, ...posts.map((post) => post.id)], + }, + ...roofAssembly, + posts, + children, + } +} diff --git a/packages/nodes/src/lean-to-extension/corner-joint.ts b/packages/nodes/src/lean-to-extension/corner-joint.ts new file mode 100644 index 0000000000..15a5c12b60 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/corner-joint.ts @@ -0,0 +1,883 @@ +import type { AnyNode, LeanToExtensionNode, WallNode } from '@pascal-app/core' +import { bendLocalPoint, isCurvedLeanTo, leanToArcFrameAtLocalX } from './arc' +import { leanToWallLocalPose, resolveLeanToLayout } from './layout' + +export type LeanToCornerSide = 'left' | 'right' +export type LeanToPlanPoint = [number, number] +export type LeanToCornerKind = 'convex' | 'concave' + +export type LeanToCornerJoint = { + side: LeanToCornerSide + kind: LeanToCornerKind + neighborId: string + neighborSide: LeanToCornerSide + roofExtension: number + roofPiece: LeanToPlanPoint[] + seam: [LeanToPlanPoint, LeanToPlanPoint] | null + beamExtension: number + gutterMitre: number + sharedPostOwner: boolean + sharedPostPosition: [number, number, number] +} + +export const LEAN_TO_CORNER_JOINTS_KEY = 'leanToCornerJoints' + +const WALL_CONNECTION_OVERLAP = 0.02 +const WALL_CONNECTION_TRIM = 0.002 +const PLAN_TOLERANCE = 1e-6 +const MIN_CORNER_ANGLE = Math.PI / 6 +const MAX_CORNER_ANGLE = (5 * Math.PI) / 6 + +function planDistance(a: readonly [number, number], b: readonly [number, number]): number { + return Math.hypot(a[0] - b[0], a[1] - b[1]) +} + +function wallFrame(wall: WallNode) { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length <= PLAN_TOLERANCE) return null + return { + along: [dx / length, dz / length] as const, + perpendicular: [-dz / length, dx / length] as const, + start: [wall.start[0], wall.start[1]] as const, + } +} + +function leanToOutwardDirection( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, +): LeanToPlanPoint | null { + const layout = resolveLeanToLayout(leanTo) + const x = layout.roofCenterX + (side === 'left' ? -layout.roofWidth / 2 : layout.roofWidth / 2) + const frame = leanToArcFrameAtLocalX(leanTo, x) + const pose = leanToWallLocalPose(wall, leanTo, 0) + const cos = Math.cos(pose.rotationY) + const sin = Math.sin(pose.rotationY) + return [frame.normal.x * cos + frame.normal.y * sin, -frame.normal.x * sin + frame.normal.y * cos] +} + +function awayFromEndDirection( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, +): LeanToPlanPoint | null { + const layout = resolveLeanToLayout(leanTo) + const endpointX = + layout.roofCenterX + (side === 'left' ? -layout.roofWidth / 2 : layout.roofWidth / 2) + const frame = leanToArcFrameAtLocalX(leanTo, endpointX) + const pose = leanToWallLocalPose(wall, leanTo, 0) + const cos = Math.cos(pose.rotationY) + const sin = Math.sin(pose.rotationY) + const inwardSign = side === 'left' ? 1 : -1 + return [ + (frame.tangent.x * cos + frame.tangent.y * sin) * inwardSign, + (-frame.tangent.x * sin + frame.tangent.y * cos) * inwardSign, + ] +} + +function awayFromEndChordDirection( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, +): LeanToPlanPoint | null { + const endpoint = endWorldPoint(wall, leanTo, side) + const opposite = endWorldPoint(wall, leanTo, side === 'left' ? 'right' : 'left') + if (!(endpoint && opposite)) return null + const dx = opposite[0] - endpoint[0] + const dz = opposite[1] - endpoint[1] + const length = Math.hypot(dx, dz) + return length > PLAN_TOLERANCE ? [dx / length, dz / length] : null +} + +function cornerKindFromDirections( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + candidateWall: WallNode, + candidate: LeanToExtensionNode, + candidateSide: LeanToCornerSide, +): LeanToCornerKind | null { + const outward = leanToOutwardDirection(wall, leanTo, side) + const candidateOutward = leanToOutwardDirection(candidateWall, candidate, candidateSide) + const away = awayFromEndDirection(wall, leanTo, side) + const candidateAway = awayFromEndDirection(candidateWall, candidate, candidateSide) + if (!(outward && candidateOutward && away && candidateAway)) return null + const candidateAcrossOwn = outward[0] * candidateAway[0] + outward[1] * candidateAway[1] + const ownAcrossCandidate = candidateOutward[0] * away[0] + candidateOutward[1] * away[1] + if (candidateAcrossOwn < -PLAN_TOLERANCE && ownAcrossCandidate < -PLAN_TOLERANCE) { + return 'convex' + } + if (candidateAcrossOwn > PLAN_TOLERANCE && ownAcrossCandidate > PLAN_TOLERANCE) { + return 'concave' + } + return null +} + +function cornerInteriorAngle( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + candidateWall: WallNode, + candidate: LeanToExtensionNode, + candidateSide: LeanToCornerSide, +): number | null { + const away = awayFromEndDirection(wall, leanTo, side) + const candidateAway = awayFromEndDirection(candidateWall, candidate, candidateSide) + if (!(away && candidateAway)) return null + const dot = Math.max(-1, Math.min(1, away[0] * candidateAway[0] + away[1] * candidateAway[1])) + return Math.acos(dot) +} + +function isSupportedHostCorner( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + candidateWall: WallNode, + candidate: LeanToExtensionNode, + candidateSide: LeanToCornerSide, +): boolean { + const away = awayFromEndChordDirection(wall, leanTo, side) + const candidateAway = awayFromEndChordDirection(candidateWall, candidate, candidateSide) + if (!(away && candidateAway)) return false + const dot = Math.max(-1, Math.min(1, away[0] * candidateAway[0] + away[1] * candidateAway[1])) + const angle = Math.acos(dot) + return angle >= MIN_CORNER_ANGLE - PLAN_TOLERANCE && angle <= MAX_CORNER_ANGLE + PLAN_TOLERANCE +} + +function leanToPointToWorld( + wall: WallNode, + leanTo: LeanToExtensionNode, + localX: number, + localZ: number, +): LeanToPlanPoint | null { + const pose = leanToWallLocalPose(wall, leanTo, 0) + const point = bendLocalPoint(leanTo, localX, localZ) + const cos = Math.cos(pose.rotationY) + const sin = Math.sin(pose.rotationY) + return [ + pose.position[0] + point.x * cos + point.y * sin, + pose.position[2] - point.x * sin + point.y * cos, + ] +} + +function worldPointToLeanTo( + wall: WallNode, + leanTo: LeanToExtensionNode, + point: readonly [number, number], +): LeanToPlanPoint | null { + const pose = leanToWallLocalPose(wall, leanTo, 0) + const dx = point[0] - pose.position[0] + const dz = point[1] - pose.position[2] + const cos = Math.cos(pose.rotationY) + const sin = Math.sin(pose.rotationY) + const bentX = dx * cos - dz * sin + const bentZ = dx * sin + dz * cos + if (!isCurvedLeanTo(leanTo)) return [bentX, bentZ] + const centerZ = leanTo.spanArcCenterZ as number + const radialSign = -(Math.sign(centerZ) || 1) + const radial = Math.hypot(bentX, bentZ - centerZ) * radialSign + const phi = Math.atan2(-bentX * radialSign, (bentZ - centerZ) * radialSign) + const signedRadius = (Math.sign(centerZ) || 1) * (leanTo.spanArcRadius as number) + return [phi * signedRadius, centerZ + radial] +} + +function extensionToRunIntersection( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + ownSideX: number, + ownZ: number, + candidateWall: WallNode, + candidate: LeanToExtensionNode, + candidateZ: number, +): number | null { + const ownOrigin = leanToPointToWorld(wall, leanTo, 0, ownZ) + const ownNext = leanToPointToWorld(wall, leanTo, 1, ownZ) + const ownBoundary = leanToPointToWorld(wall, leanTo, ownSideX, ownZ) + const candidateOrigin = leanToPointToWorld(candidateWall, candidate, 0, candidateZ) + const candidateNext = leanToPointToWorld(candidateWall, candidate, 1, candidateZ) + if (!(ownOrigin && ownNext && ownBoundary && candidateOrigin && candidateNext)) return null + + const ownDirection: LeanToPlanPoint = [ownNext[0] - ownOrigin[0], ownNext[1] - ownOrigin[1]] + const sideSign = side === 'left' ? -1 : 1 + if (isCurvedLeanTo(leanTo) && !isCurvedLeanTo(candidate)) { + const candidateDirection: LeanToPlanPoint = [ + candidateNext[0] - candidateOrigin[0], + candidateNext[1] - candidateOrigin[1], + ] + const directionLength = Math.hypot(candidateDirection[0], candidateDirection[1]) + if (directionLength <= PLAN_TOLERANCE) return null + const direction: LeanToPlanPoint = [ + candidateDirection[0] / directionLength, + candidateDirection[1] / directionLength, + ] + const pose = leanToWallLocalPose(wall, leanTo, 0) + const centerZ = leanTo.spanArcCenterZ as number + const cos = Math.cos(pose.rotationY) + const sin = Math.sin(pose.rotationY) + const center: LeanToPlanPoint = [ + pose.position[0] + centerZ * sin, + pose.position[2] + centerZ * cos, + ] + const offset: LeanToPlanPoint = [candidateOrigin[0] - center[0], candidateOrigin[1] - center[1]] + const projection = offset[0] * direction[0] + offset[1] * direction[1] + const radius = Math.abs(ownZ - centerZ) + const discriminant = + projection * projection - (offset[0] * offset[0] + offset[1] * offset[1] - radius * radius) + if (discriminant < -PLAN_TOLERANCE) return null + const root = Math.sqrt(Math.max(0, discriminant)) + const extensions = [-projection - root, -projection + root].flatMap((distance) => { + const intersection: LeanToPlanPoint = [ + candidateOrigin[0] + direction[0] * distance, + candidateOrigin[1] + direction[1] * distance, + ] + const local = worldPointToLeanTo(wall, leanTo, intersection) + if (!local) return [] + const extension = sideSign * (local[0] - ownSideX) + return extension >= -PLAN_TOLERANCE ? [Math.max(0, extension)] : [] + }) + return extensions.length > 0 ? Math.min(...extensions) : null + } + if (isCurvedLeanTo(candidate) && !isCurvedLeanTo(leanTo)) { + const directionLength = Math.hypot(ownDirection[0], ownDirection[1]) + if (directionLength <= PLAN_TOLERANCE) return null + const direction: LeanToPlanPoint = [ + (ownDirection[0] / directionLength) * sideSign, + (ownDirection[1] / directionLength) * sideSign, + ] + const pose = leanToWallLocalPose(candidateWall, candidate, 0) + const centerZ = candidate.spanArcCenterZ as number + const cos = Math.cos(pose.rotationY) + const sin = Math.sin(pose.rotationY) + const center: LeanToPlanPoint = [ + pose.position[0] + centerZ * sin, + pose.position[2] + centerZ * cos, + ] + const offset: LeanToPlanPoint = [ownBoundary[0] - center[0], ownBoundary[1] - center[1]] + const projection = offset[0] * direction[0] + offset[1] * direction[1] + const radius = Math.abs(candidateZ - centerZ) + const discriminant = + projection * projection - (offset[0] * offset[0] + offset[1] * offset[1] - radius * radius) + if (discriminant < -PLAN_TOLERANCE) return null + const root = Math.sqrt(Math.max(0, discriminant)) + const intersections = [-projection - root, -projection + root].filter( + (distance) => distance >= -PLAN_TOLERANCE, + ) + return intersections.length > 0 ? Math.max(0, Math.min(...intersections)) : null + } + const candidateDirection: LeanToPlanPoint = [ + candidateNext[0] - candidateOrigin[0], + candidateNext[1] - candidateOrigin[1], + ] + const cross = ownDirection[0] * candidateDirection[1] - ownDirection[1] * candidateDirection[0] + if (Math.abs(cross) <= PLAN_TOLERANCE) return null + const deltaX = candidateOrigin[0] - ownOrigin[0] + const deltaZ = candidateOrigin[1] - ownOrigin[1] + const alongOwn = (deltaX * candidateDirection[1] - deltaZ * candidateDirection[0]) / cross + const intersection: LeanToPlanPoint = [ + ownOrigin[0] + ownDirection[0] * alongOwn, + ownOrigin[1] + ownDirection[1] * alongOwn, + ] + return ( + sideSign * + ((intersection[0] - ownBoundary[0]) * ownDirection[0] + + (intersection[1] - ownBoundary[1]) * ownDirection[1]) + ) +} + +function leanToTopHeightAtWorld( + wall: WallNode, + leanTo: LeanToExtensionNode, + point: readonly [number, number], +): number | null { + const local = worldPointToLeanTo(wall, leanTo, point) + if (!local) return null + const layout = resolveLeanToLayout(leanTo) + return leanTo.position[1] + layout.highEdgeHeight - local[1] * Math.tan(layout.pitchRadians) +} + +function roofPlanEdges(leanTo: LeanToExtensionNode): { back: number; front: number } { + const layout = resolveLeanToLayout(leanTo) + const depth = layout.roofRun + WALL_CONNECTION_OVERLAP + const centerZ = + depth / 2 - Math.max(0, leanTo.highOverhang) - WALL_CONNECTION_TRIM - WALL_CONNECTION_OVERLAP + return { + back: centerZ - depth / 2 + (leanTo.highOverhang > 0 ? 0 : WALL_CONNECTION_TRIM), + front: centerZ + depth / 2, + } +} + +function gutterAwayFromJointDirection( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + extension: number, +): LeanToPlanPoint | null { + if (Math.abs(extension) <= PLAN_TOLERANCE) return awayFromEndDirection(wall, leanTo, side) + const layout = resolveLeanToLayout(leanTo) + const sideSign = side === 'left' ? -1 : 1 + const baseX = layout.roofCenterX + sideSign * (layout.roofWidth / 2) + const front = roofPlanEdges(leanTo).front + const base = leanToPointToWorld(wall, leanTo, baseX, front) + const end = leanToPointToWorld(wall, leanTo, baseX + sideSign * extension, front) + if (!(base && end)) return null + const dx = base[0] - end[0] + const dz = base[1] - end[1] + const length = Math.hypot(dx, dz) + return length > PLAN_TOLERANCE ? [dx / length, dz / length] : null +} + +function endWorldPoint( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, +): LeanToPlanPoint | null { + const layout = resolveLeanToLayout(leanTo) + const x = side === 'left' ? -layout.span / 2 : layout.span / 2 + return leanToPointToWorld(wall, leanTo, x, 0) +} + +function candidateSideAtPoint( + wall: WallNode, + leanTo: LeanToExtensionNode, + point: readonly [number, number], + tolerance: number, +): LeanToCornerSide | null { + const left = endWorldPoint(wall, leanTo, 'left') + const right = endWorldPoint(wall, leanTo, 'right') + if (left && planDistance(left, point) <= tolerance) return 'left' + if (right && planDistance(right, point) <= tolerance) return 'right' + return null +} + +function clipToRetainedRoofSide( + polygon: readonly LeanToPlanPoint[], + heightDelta: (point: readonly [number, number]) => number | null, + retainedSign: number, +): LeanToPlanPoint[] { + const clipped: LeanToPlanPoint[] = [] + for (let index = 0; index < polygon.length; index++) { + const current = polygon[index]! + const next = polygon[(index + 1) % polygon.length]! + const currentDelta = heightDelta(current) + const nextDelta = heightDelta(next) + if (currentDelta === null || nextDelta === null) return [] + const currentInside = currentDelta * retainedSign >= -PLAN_TOLERANCE + const nextInside = nextDelta * retainedSign >= -PLAN_TOLERANCE + if (currentInside) clipped.push([current[0], current[1]]) + if (currentInside === nextInside) continue + const ratio = currentDelta / (currentDelta - nextDelta) + clipped.push([ + current[0] + (next[0] - current[0]) * ratio, + current[1] + (next[1] - current[1]) * ratio, + ]) + } + return clipped.filter( + (point, index) => index === 0 || planDistance(point, clipped[index - 1]!) > PLAN_TOLERANCE, + ) +} + +function roofExtensionBand( + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + extension: number, +): LeanToPlanPoint[] { + const layout = resolveLeanToLayout(leanTo) + const edges = roofPlanEdges(leanTo) + const sideSign = side === 'left' ? -1 : 1 + const originalSideX = layout.roofCenterX + sideSign * (layout.roofWidth / 2) + const extendedSideX = originalSideX + sideSign * extension + return [ + [originalSideX, edges.back], + [extendedSideX, edges.back], + [extendedSideX, edges.front], + [originalSideX, edges.front], + ] +} + +function roofBasePolygon(leanTo: LeanToExtensionNode): LeanToPlanPoint[] { + const layout = resolveLeanToLayout(leanTo) + const edges = roofPlanEdges(leanTo) + return [ + [layout.roofCenterX - layout.roofWidth / 2, edges.back], + [layout.roofCenterX + layout.roofWidth / 2, edges.back], + [layout.roofCenterX + layout.roofWidth / 2, edges.front], + [layout.roofCenterX - layout.roofWidth / 2, edges.front], + ] +} + +function polygonSignedArea(polygon: readonly LeanToPlanPoint[]): number { + let area = 0 + for (let index = 0; index < polygon.length; index++) { + const current = polygon[index]! + const next = polygon[(index + 1) % polygon.length]! + area += current[0] * next[1] - next[0] * current[1] + } + return area / 2 +} + +function intersectConvexPolygons( + subject: readonly LeanToPlanPoint[], + clip: readonly LeanToPlanPoint[], +): LeanToPlanPoint[] { + let result = subject.map((point) => [point[0], point[1]] as LeanToPlanPoint) + const orientation = Math.sign(polygonSignedArea(clip)) || 1 + for (let clipIndex = 0; clipIndex < clip.length && result.length > 0; clipIndex++) { + const edgeStart = clip[clipIndex]! + const edgeEnd = clip[(clipIndex + 1) % clip.length]! + const input = result + result = [] + const edgeSide = (point: readonly [number, number]) => + orientation * + ((edgeEnd[0] - edgeStart[0]) * (point[1] - edgeStart[1]) - + (edgeEnd[1] - edgeStart[1]) * (point[0] - edgeStart[0])) + for (let index = 0; index < input.length; index++) { + const current = input[index]! + const next = input[(index + 1) % input.length]! + const currentSide = edgeSide(current) + const nextSide = edgeSide(next) + const currentInside = currentSide >= -PLAN_TOLERANCE + const nextInside = nextSide >= -PLAN_TOLERANCE + if (currentInside) result.push(current) + if (currentInside === nextInside) continue + const ratio = currentSide / (currentSide - nextSide) + result.push([ + current[0] + (next[0] - current[0]) * ratio, + current[1] + (next[1] - current[1]) * ratio, + ]) + } + } + return result +} + +function sharedRoofSeam( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + extension: number, + candidateWall: WallNode, + candidate: LeanToExtensionNode, + candidateSide: LeanToCornerSide, + candidateExtension: number, + kind: LeanToCornerKind, +): [LeanToPlanPoint, LeanToPlanPoint] | null { + const ownPolygon = + kind === 'convex' ? roofExtensionBand(leanTo, side, extension) : roofBasePolygon(leanTo) + const ownBand = ownPolygon.flatMap((point) => { + const world = leanToPointToWorld(wall, leanTo, point[0], point[1]) + return world ? [world] : [] + }) + const candidatePolygon = + kind === 'convex' + ? roofExtensionBand(candidate, candidateSide, candidateExtension) + : roofBasePolygon(candidate) + const candidateBand = candidatePolygon.flatMap((point) => { + const world = leanToPointToWorld(candidateWall, candidate, point[0], point[1]) + return world ? [world] : [] + }) + if (ownBand.length < 3 || candidateBand.length < 3) return null + const overlap = intersectConvexPolygons(ownBand, candidateBand) + const seamWorld: LeanToPlanPoint[] = [] + const heightDelta = (point: readonly [number, number]) => { + const ownHeight = leanToTopHeightAtWorld(wall, leanTo, point) + const candidateHeight = leanToTopHeightAtWorld(candidateWall, candidate, point) + return ownHeight === null || candidateHeight === null ? null : ownHeight - candidateHeight + } + for (let index = 0; index < overlap.length; index++) { + const current = overlap[index]! + const next = overlap[(index + 1) % overlap.length]! + const currentDelta = heightDelta(current) + const nextDelta = heightDelta(next) + if (currentDelta === null || nextDelta === null) return null + if (Math.abs(currentDelta) <= PLAN_TOLERANCE) seamWorld.push(current) + if (currentDelta * nextDelta >= 0) continue + const ratio = currentDelta / (currentDelta - nextDelta) + seamWorld.push([ + current[0] + (next[0] - current[0]) * ratio, + current[1] + (next[1] - current[1]) * ratio, + ]) + } + const unique = seamWorld.filter( + (point, index) => + seamWorld.findIndex( + (candidatePoint) => planDistance(point, candidatePoint) <= PLAN_TOLERANCE, + ) === index, + ) + if (unique.length < 2) return null + let endpoints: [LeanToPlanPoint, LeanToPlanPoint] = [unique[0]!, unique[1]!] + for (const first of unique) { + for (const second of unique) { + if (planDistance(first, second) > planDistance(endpoints[0], endpoints[1])) { + endpoints = [first, second] + } + } + } + const localized = endpoints.map((point) => worldPointToLeanTo(wall, leanTo, point)) + return localized[0] && localized[1] ? [localized[0], localized[1]] : null +} + +function resolveConcaveRoofPiece( + leanTo: LeanToExtensionNode, + wall: WallNode, + side: LeanToCornerSide, + candidate: LeanToExtensionNode, + candidateWall: WallNode, +): { piece: LeanToPlanPoint[]; seam: [LeanToPlanPoint, LeanToPlanPoint] | null } { + const layout = resolveLeanToLayout(leanTo) + const edges = roofPlanEdges(leanTo) + const sideSign = side === 'left' ? -1 : 1 + const originalSideX = layout.roofCenterX + sideSign * (layout.roofWidth / 2) + const heightDelta = (point: readonly [number, number]): number | null => { + const worldPoint = leanToPointToWorld(wall, leanTo, point[0], point[1]) + if (!worldPoint) return null + const ownHeight = leanToTopHeightAtWorld(wall, leanTo, worldPoint) + const candidateHeight = leanToTopHeightAtWorld(candidateWall, candidate, worldPoint) + return ownHeight === null || candidateHeight === null ? null : ownHeight - candidateHeight + } + const probeDelta = heightDelta([ + originalSideX - sideSign * Math.min(0.1, layout.roofWidth / 4), + edges.back, + ]) + if (probeDelta === null || Math.abs(probeDelta) <= PLAN_TOLERANCE) { + return { piece: [], seam: null } + } + const base = roofBasePolygon(leanTo) + const piece = clipToRetainedRoofSide(base, heightDelta, Math.sign(probeDelta)) + const seamPoints: LeanToPlanPoint[] = [] + for (let index = 0; index < base.length; index++) { + const current = base[index]! + const next = base[(index + 1) % base.length]! + const currentDelta = heightDelta(current) + const nextDelta = heightDelta(next) + if (currentDelta === null || nextDelta === null) continue + if (Math.abs(currentDelta) <= PLAN_TOLERANCE) seamPoints.push(current) + if (currentDelta * nextDelta >= 0) continue + const ratio = currentDelta / (currentDelta - nextDelta) + seamPoints.push([ + current[0] + (next[0] - current[0]) * ratio, + current[1] + (next[1] - current[1]) * ratio, + ]) + } + const uniqueSeam = seamPoints.filter( + (point, index) => + seamPoints.findIndex((other) => planDistance(point, other) <= PLAN_TOLERANCE) === index, + ) + return { + piece, + seam: uniqueSeam.length >= 2 ? [uniqueSeam[0]!, uniqueSeam[1]!] : null, + } +} + +export function applyLeanToCornerRoofPieces( + base: LeanToPlanPoint[], + joints: Partial>, +): LeanToPlanPoint[][] { + let retained = base + const additions: LeanToPlanPoint[][] = [] + for (const side of ['left', 'right'] as const) { + const joint = joints[side] + if (!joint || joint.roofPiece.length < 3) continue + if (joint.kind === 'concave') { + retained = intersectConvexPolygons(retained, joint.roofPiece) + } else { + additions.push(joint.roofPiece) + } + } + return [...(retained.length >= 3 ? [retained] : []), ...additions] +} + +function resolveRoofPiece( + leanTo: LeanToExtensionNode, + wall: WallNode, + side: LeanToCornerSide, + extension: number, + candidate: LeanToExtensionNode, + candidateWall: WallNode, +): { piece: LeanToPlanPoint[]; seam: [LeanToPlanPoint, LeanToPlanPoint] | null } { + const layout = resolveLeanToLayout(leanTo) + const edges = roofPlanEdges(leanTo) + const sideSign = side === 'left' ? -1 : 1 + const originalSideX = layout.roofCenterX + sideSign * (layout.roofWidth / 2) + const heightDelta = (point: readonly [number, number]): number | null => { + const worldPoint = leanToPointToWorld(wall, leanTo, point[0], point[1]) + if (!worldPoint) return null + const ownHeight = leanToTopHeightAtWorld(wall, leanTo, worldPoint) + const candidateHeight = leanToTopHeightAtWorld(candidateWall, candidate, worldPoint) + return ownHeight === null || candidateHeight === null ? null : ownHeight - candidateHeight + } + const probeDelta = heightDelta([ + originalSideX - sideSign * Math.min(0.1, layout.roofWidth / 4), + (edges.back + edges.front) / 2, + ]) + if (probeDelta === null || Math.abs(probeDelta) <= PLAN_TOLERANCE) { + return { piece: [], seam: null } + } + const band = roofExtensionBand(leanTo, side, extension) + const piece = clipToRetainedRoofSide(band, heightDelta, Math.sign(probeDelta)) + const seamPoints: LeanToPlanPoint[] = [] + for (let index = 0; index < band.length; index++) { + const current = band[index]! + const next = band[(index + 1) % band.length]! + const currentDelta = heightDelta(current) + const nextDelta = heightDelta(next) + if (currentDelta === null || nextDelta === null) continue + if (Math.abs(currentDelta) <= PLAN_TOLERANCE) seamPoints.push(current) + if (currentDelta * nextDelta >= 0) continue + const ratio = currentDelta / (currentDelta - nextDelta) + seamPoints.push([ + current[0] + (next[0] - current[0]) * ratio, + current[1] + (next[1] - current[1]) * ratio, + ]) + } + const uniqueSeam = seamPoints.filter( + (point, index) => + seamPoints.findIndex((other) => planDistance(point, other) <= PLAN_TOLERANCE) === index, + ) + return { + piece, + seam: uniqueSeam.length >= 2 ? [uniqueSeam[0]!, uniqueSeam[1]!] : null, + } +} + +function resolveCurvedStraightRoofPiece( + leanTo: LeanToExtensionNode, + wall: WallNode, + side: LeanToCornerSide, + extension: number, + candidate: LeanToExtensionNode, + candidateWall: WallNode, + candidateSide: LeanToCornerSide, + candidateExtension: number, +): { piece: LeanToPlanPoint[]; seam: [LeanToPlanPoint, LeanToPlanPoint] | null } | null { + const ownCurved = isCurvedLeanTo(leanTo) + const candidateCurved = isCurvedLeanTo(candidate) + if (ownCurved === candidateCurved) return null + + const layout = resolveLeanToLayout(leanTo) + const edges = roofPlanEdges(leanTo) + const sideSign = side === 'left' ? -1 : 1 + const sideX = layout.roofCenterX + sideSign * (layout.roofWidth / 2) + const curved = ownCurved ? leanTo : candidate + const curvedWall = ownCurved ? wall : candidateWall + const curvedSide = ownCurved ? side : candidateSide + const curvedExtension = ownCurved ? extension : candidateExtension + const curvedLayout = resolveLeanToLayout(curved) + const curvedEdges = roofPlanEdges(curved) + const curvedSideSign = curvedSide === 'left' ? -1 : 1 + const curvedSideX = curvedLayout.roofCenterX + curvedSideSign * (curvedLayout.roofWidth / 2) + const curvedLowX = curvedSideX + curvedSideSign * curvedExtension + const seamWorld = [ + leanToPointToWorld(curvedWall, curved, curvedSideX, curvedEdges.back), + leanToPointToWorld(curvedWall, curved, curvedLowX, curvedEdges.front), + ] + if (seamWorld.some((point) => !point)) return null + const localized = seamWorld.map((point) => worldPointToLeanTo(wall, leanTo, point!)) + if (localized.some((point) => !point)) return null + const seamPoints = localized as [LeanToPlanPoint, LeanToPlanPoint] + const seam: [LeanToPlanPoint, LeanToPlanPoint] = [seamPoints[0]!, seamPoints.at(-1)!] + return { + piece: [...seamPoints, [sideX, edges.front]], + seam, + } +} + +export function resolveLeanToCornerJoints( + leanTo: LeanToExtensionNode, + wall: WallNode | undefined, + nodes: Record | undefined, +): Partial> { + if (!leanTo.autoMiterCorners || !wall || !nodes) return {} + if (!wallFrame(wall)) return {} + const tolerance = Math.max( + 0.35, + (wall.thickness ?? 0.1) + Math.max(leanTo.leftOverhang, leanTo.rightOverhang), + ) + const joints: Partial> = {} + + for (const side of ['left', 'right'] as const) { + const endpoint = endWorldPoint(wall, leanTo, side) + if (!endpoint) continue + for (const candidate of Object.values(nodes)) { + if (candidate.type !== 'lean-to-extension' || candidate.id === leanTo.id) continue + if (!candidate.autoMiterCorners) continue + const candidateWall = candidate.parentId ? nodes[candidate.parentId] : undefined + if (candidateWall?.type !== 'wall' || candidateWall.parentId !== wall.parentId) continue + if (!wallFrame(candidateWall)) continue + const neighborSide = candidateSideAtPoint(candidateWall, candidate, endpoint, tolerance) + if (!neighborSide) continue + const kind = cornerKindFromDirections( + wall, + leanTo, + side, + candidateWall, + candidate, + neighborSide, + ) + if (!kind) continue + if (kind === 'concave' && (isCurvedLeanTo(leanTo) || isCurvedLeanTo(candidate))) continue + if (!isSupportedHostCorner(wall, leanTo, side, candidateWall, candidate, neighborSide)) { + continue + } + const interiorAngle = cornerInteriorAngle( + wall, + leanTo, + side, + candidateWall, + candidate, + neighborSide, + ) + if (interiorAngle === null) continue + + const candidateLayout = resolveLeanToLayout(candidate) + const layout = resolveLeanToLayout(leanTo) + const sideSign = side === 'left' ? -1 : 1 + const ownEdges = roofPlanEdges(leanTo) + const candidateEdges = roofPlanEdges(candidate) + const roofSideX = layout.roofCenterX + sideSign * (layout.roofWidth / 2) + const roofExtension = + extensionToRunIntersection( + wall, + leanTo, + side, + roofSideX, + ownEdges.front, + candidateWall, + candidate, + candidateEdges.front, + ) ?? 0 + const candidateSideSign = neighborSide === 'left' ? -1 : 1 + const candidateRoofSideX = + candidateLayout.roofCenterX + candidateSideSign * (candidateLayout.roofWidth / 2) + const candidateRoofExtension = + extensionToRunIntersection( + candidateWall, + candidate, + neighborSide, + candidateRoofSideX, + candidateEdges.front, + wall, + leanTo, + ownEdges.front, + ) ?? 0 + const curvedStraightRoof = + kind === 'convex' + ? resolveCurvedStraightRoofPiece( + leanTo, + wall, + side, + roofExtension, + candidate, + candidateWall, + neighborSide, + candidateRoofExtension, + ) + : null + const roof = + curvedStraightRoof ?? + (kind === 'convex' + ? resolveRoofPiece(leanTo, wall, side, roofExtension, candidate, candidateWall) + : resolveConcaveRoofPiece(leanTo, wall, side, candidate, candidateWall)) + const seam = curvedStraightRoof + ? roof.seam + : sharedRoofSeam( + wall, + leanTo, + side, + roofExtension, + candidateWall, + candidate, + neighborSide, + candidateRoofExtension, + kind, + ) + const beamExtension = + extensionToRunIntersection( + wall, + leanTo, + side, + sideSign * (layout.span / 2), + layout.beamZ, + candidateWall, + candidate, + candidateLayout.beamZ, + ) ?? 0 + const gutterAway = gutterAwayFromJointDirection(wall, leanTo, side, roofExtension) + const candidateGutterAway = gutterAwayFromJointDirection( + candidateWall, + candidate, + neighborSide, + candidateRoofExtension, + ) + const gutterInteriorAngle = + gutterAway && candidateGutterAway + ? Math.acos( + Math.max( + -1, + Math.min( + 1, + gutterAway[0] * candidateGutterAway[0] + gutterAway[1] * candidateGutterAway[1], + ), + ), + ) + : interiorAngle + joints[side] = { + side, + kind, + neighborId: candidate.id, + neighborSide, + roofExtension, + roofPiece: roof.piece, + seam: seam ?? roof.seam, + beamExtension, + gutterMitre: (kind === 'concave' ? -1 : 1) * ((Math.PI - gutterInteriorAngle) / 2), + sharedPostOwner: String(leanTo.id) < String(candidate.id), + sharedPostPosition: [ + (side === 'left' ? -layout.span / 2 : layout.span / 2) + + (side === 'left' ? -beamExtension : beamExtension), + 0, + layout.beamZ, + ], + } + break + } + } + return joints +} + +export type LeanToCornerJointMetadata = Partial< + Record< + LeanToCornerSide, + Pick + > +> + +export function leanToCornerJointMetadata( + joints: Partial>, +): LeanToCornerJointMetadata { + return Object.fromEntries( + Object.entries(joints).map(([side, joint]) => [ + side, + joint + ? { + beamExtension: joint.beamExtension, + gutterMitre: joint.gutterMitre, + seam: joint.seam, + sharedPostOwner: joint.sharedPostOwner, + } + : undefined, + ]), + ) +} + +export function readLeanToCornerJointMetadata( + leanTo: LeanToExtensionNode, +): LeanToCornerJointMetadata { + const metadata = leanTo.metadata + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {} + const value = (metadata as Record)[LEAN_TO_CORNER_JOINTS_KEY] + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as LeanToCornerJointMetadata) + : {} +} diff --git a/packages/nodes/src/lean-to-extension/definition.test.ts b/packages/nodes/src/lean-to-extension/definition.test.ts new file mode 100644 index 0000000000..5eefb9be91 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/definition.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type HandleDescriptor, + type LeanToExtensionNode, + LeanToExtensionNode as LeanToExtensionNodeSchema, + type LinearResizeHandle, +} from '@pascal-app/core' +import { leanToExtensionDefinition } from './definition' +import { resolveLeanToLayout } from './layout' + +function node(overrides: Partial = {}): LeanToExtensionNode { + return LeanToExtensionNodeSchema.parse({ + id: 'leanto_test', + parentId: 'wall_test', + position: [6, 0, 0.05], + span: 4, + projection: 3, + autoSpan: true, + ...overrides, + }) +} + +function handles(): HandleDescriptor[] { + const descriptors = leanToExtensionDefinition.handles + if (!Array.isArray(descriptors)) throw new Error('Expected static lean-to handles') + return descriptors as HandleDescriptor[] +} + +function linearHandle( + axis: 'x' | 'z', + anchor: 'min' | 'max', +): LinearResizeHandle { + const handle = handles().find( + (h): h is LinearResizeHandle => + h.kind === 'linear-resize' && h.axis === axis && h.anchor === anchor, + ) + if (!handle) throw new Error(`Missing ${axis}/${anchor} handle`) + return handle +} + +function spanHandle(anchor: 'min' | 'max'): LinearResizeHandle { + return linearHandle('x', anchor) +} + +describe('lean-to extension span handles', () => { + test('exposes right and left span arrows on the whole extension', () => { + expect(spanHandle('min').placement.rotationY?.(node(), undefined as never)).toBe(0) + expect(spanHandle('max').placement.rotationY?.(node(), undefined as never)).toBe(Math.PI) + }) + + test('places span arrows at the low roof edge height', () => { + const leanTo = node() + const layout = resolveLeanToLayout(leanTo) + + expect(spanHandle('min').placement.position(leanTo, undefined as never)).toEqual([ + leanTo.span / 2 + 0.3, + layout.lowEdgeHeight + 0.25, + leanTo.projection, + ]) + expect(spanHandle('max').placement.position(leanTo, undefined as never)).toEqual([ + -(leanTo.span / 2 + 0.3), + layout.lowEdgeHeight + 0.25, + leanTo.projection, + ]) + }) + + test('places projection arrow at the same low roof edge height', () => { + const leanTo = node() + const layout = resolveLeanToLayout(leanTo) + + expect(linearHandle('z', 'min').placement.position(leanTo, undefined as never)).toEqual([ + 0, + layout.lowEdgeHeight + 0.25, + leanTo.projection, + ]) + }) + + test('resizes span only from the dragged side', () => { + const leanTo = node() + + expect(spanHandle('min').apply(leanTo, 6, undefined as never)).toMatchObject({ + span: 6, + autoSpan: false, + position: [7, 0, 0.05], + }) + expect(spanHandle('max').apply(leanTo, 6, undefined as never)).toMatchObject({ + span: 6, + autoSpan: false, + position: [5, 0, 0.05], + }) + }) + + test('resizes span from the visual side when placed on the opposite wall face', () => { + const leanTo = node({ rotation: [0, Math.PI, 0], position: [6, 0, -0.05] }) + + expect(spanHandle('min').apply(leanTo, 6, undefined as never)).toMatchObject({ + span: 6, + autoSpan: false, + position: [5, 0, -0.05], + }) + expect(spanHandle('max').apply(leanTo, 6, undefined as never)).toMatchObject({ + span: 6, + autoSpan: false, + position: [7, 0, -0.05], + }) + }) + + test('previews managed roof-segment span while dragging', () => { + const leanTo = node({ children: ['roof_test' as never] }) + const nodes = { + [leanTo.id]: leanTo, + roof_test: { + id: 'roof_test', + type: 'roof', + parentId: leanTo.id, + metadata: { managedByLeanTo: leanTo.id, leanToRole: 'roof' }, + children: ['rseg_test'], + }, + rseg_test: { + id: 'rseg_test', + type: 'roof-segment', + parentId: 'roof_test', + metadata: { managedByLeanTo: leanTo.id, leanToRole: 'roof-segment' }, + children: [], + }, + } as unknown as Record + + const preview = new Map( + spanHandle('min').previewOverrides?.(leanTo, 6, { nodes: () => nodes } as never) ?? [], + ) + + expect(preview.get('rseg_test' as never)).toMatchObject({ + roofType: 'shed', + width: 6 + leanTo.leftOverhang + leanTo.rightOverhang, + }) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/definition.ts b/packages/nodes/src/lean-to-extension/definition.ts new file mode 100644 index 0000000000..af914d0f72 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/definition.ts @@ -0,0 +1,305 @@ +import type { + AnyNode, + AnyNodeId, + HandleDescriptor, + NodeDefinition, + SceneApi, + WallNode, +} from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { + isManagedLeanToNode, + isManagedLeanToPost, + leanToDownspoutLayoutPatch, + leanToGutterLayoutPatch, + leanToPostLayoutPatch, + leanToRoofSegmentLayoutPatch, + managedLeanToPostIndex, + managedLeanToPostSide, + resolveLeanToPostBaseY, + resolveLeanToPostGutterSetback, +} from './assembly' +import { buildLeanToExtensionFloorplan } from './floorplan' +import { leanToResizeAffordance } from './floorplan-affordances' +import { leanToFloorplanMoveTarget } from './floorplan-move' +import { buildLeanToExtensionGeometry, leanToExtensionGeometryKey } from './geometry' +import { resolveLeanToLayout } from './layout' +import { leanToPaint } from './paint' +import { deriveLeanToResizePatch, leanToExtensionParametrics } from './parametrics' +import { applyLeanToRoofAttachment, resolveLeanToRoofAttachment } from './roof-attachment' +import { LeanToExtensionNode } from './schema' +import { leanToSlots } from './slots' + +const HEIGHT_HANDLE_OFFSET = 0.25 +const SPAN_HANDLE_OFFSET = 0.3 +const ROOF_EDGE_SNAP_TOLERANCE = 0.3 + +function resolveHostWall(node: LeanToExtensionNode, sceneApi: SceneApi): WallNode | null { + if (!node.parentId) return null + const wall = sceneApi.get(node.parentId as AnyNodeId) + return wall?.type === 'wall' ? wall : null +} + +function highEdgeHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + shape: 'tracker', + min: 0.8, + max: 10, + currentValue: (node) => node.highEdgeHeight, + magneticSnap: (node, newValue, sceneApi) => { + const wall = resolveHostWall(node, sceneApi) + if (!wall) return newValue + const attachment = resolveLeanToRoofAttachment( + { ...node, highEdgeHeight: newValue }, + wall, + sceneApi.nodes(), + ) + return attachment && + Math.abs(attachment.highEdgeHeight - newValue) <= ROOF_EDGE_SNAP_TOLERANCE + ? attachment.highEdgeHeight + : newValue + }, + apply: (node, newValue, sceneApi) => { + const wall = resolveHostWall(node, sceneApi) + const attachment = wall + ? resolveLeanToRoofAttachment({ ...node, highEdgeHeight: newValue }, wall, sceneApi.nodes()) + : null + if ( + attachment && + Math.abs(attachment.highEdgeHeight - newValue) <= ROOF_EDGE_SNAP_TOLERANCE + ) { + const connected = applyLeanToRoofAttachment(node, attachment) + return { + highEdgeHeight: connected.highEdgeHeight, + lowEdgeHeight: connected.lowEdgeHeight, + connectionMode: connected.connectionMode, + hostRoofId: connected.hostRoofId, + hostRoofSegmentId: connected.hostRoofSegmentId, + hostRoofEdge: connected.hostRoofEdge, + hostRoofEdgeRange: connected.hostRoofEdgeRange, + connectionInset: connected.connectionInset, + span: connected.span, + position: connected.position, + roofThickness: connected.roofThickness, + shingleThickness: connected.shingleThickness, + } + } + return { + ...deriveLeanToResizePatch(node, { highEdgeHeight: newValue }), + connectionMode: 'manual', + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } + }, + placement: { + position: (node) => [0, node.highEdgeHeight + HEIGHT_HANDLE_OFFSET, 0], + }, + measureLabel: 'High edge height', + } +} + +function leanToManagedPreviewOverrides( + node: LeanToExtensionNode, + patch: Partial, + sceneApi: SceneApi, +): ReadonlyArray]> { + const next = { ...node, ...patch } as LeanToExtensionNode + const nodes = sceneApi.nodes() as Record + const entries: Array]> = [] + + const wall = next.parentId ? nodes[next.parentId as AnyNodeId] : undefined + for (const childId of next.children) { + const child = nodes[childId as AnyNodeId] + if (!child) continue + + if (child.type === 'column' && isManagedLeanToPost(child, next.id)) { + const index = managedLeanToPostIndex(child) + if (index === null) continue + const side = managedLeanToPostSide(child) + const baseY = + wall?.type === 'wall' ? resolveLeanToPostBaseY(next, wall, nodes, index, side) : 0 + const gutterSetback = side === 'low' ? resolveLeanToPostGutterSetback(next, child) : 0 + entries.push([ + child.id as AnyNodeId, + leanToPostLayoutPatch(next, index, baseY, gutterSetback, side) as Partial, + ]) + continue + } + + if (child.type !== 'roof' || !isManagedLeanToNode(child, next.id, 'roof')) continue + const segment = child.children + .map((id) => nodes[id as AnyNodeId]) + .find( + (candidate) => + candidate?.type === 'roof-segment' && + isManagedLeanToNode(candidate, next.id, 'roof-segment'), + ) + if (segment?.type !== 'roof-segment') continue + + const segmentPatch = leanToRoofSegmentLayoutPatch(next, nodes) + entries.push([segment.id as AnyNodeId, segmentPatch as Partial]) + + const nextSegment = { ...segment, ...segmentPatch } + const gutter = segment.children + .map((id) => nodes[id as AnyNodeId]) + .find( + (candidate) => + candidate?.type === 'gutter' && isManagedLeanToNode(candidate, next.id, 'gutter'), + ) + if (gutter?.type !== 'gutter') continue + const gutterPatch = leanToGutterLayoutPatch(nextSegment, next, gutter, nodes) + entries.push([gutter.id as AnyNodeId, gutterPatch as Partial]) + + const nextGutter = { ...gutter, ...gutterPatch } + const downspout = segment.children + .map((id) => nodes[id as AnyNodeId]) + .find( + (candidate) => + candidate?.type === 'downspout' && isManagedLeanToNode(candidate, next.id, 'downspout'), + ) + if (downspout?.type === 'downspout') { + entries.push([ + downspout.id as AnyNodeId, + leanToDownspoutLayoutPatch(nextSegment, nextGutter, next, downspout) as Partial, + ]) + } + } + + return entries +} + +function spanPatch( + node: LeanToExtensionNode, + span: number, + side: 'left' | 'right', +): Partial { + const localSign = side === 'right' ? 1 : -1 + const sign = Math.cos(node.rotation[1]) >= 0 ? localSign : -localSign + return { + span, + autoSpan: false, + position: [ + node.position[0] + (sign * (span - node.span)) / 2, + node.position[1], + node.position[2], + ], + } +} + +function spanHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: 0.5, + max: 100, + currentValue: (node) => node.span, + apply: (node, span) => spanPatch(node, span, side), + previewOverrides: (node, span, sceneApi) => + leanToManagedPreviewOverrides(node, spanPatch(node, span, side), sceneApi), + placement: { + position: (node) => { + const layout = resolveLeanToLayout(node) + return [ + sign * (node.span / 2 + SPAN_HANDLE_OFFSET), + layout.lowEdgeHeight + HEIGHT_HANDLE_OFFSET, + node.projection, + ] + }, + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + measureLabel: 'Span', + } +} + +const leanToExtensionHandles: HandleDescriptor[] = [highEdgeHeightHandle()] +leanToExtensionHandles.push({ + kind: 'linear-resize', + axis: 'z', + anchor: 'min', + min: 0.5, + max: 10, + currentValue: (node) => node.projection, + apply: (node, projection) => ({ + projection, + ...deriveLeanToResizePatch(node, { projection }), + }), + placement: { + position: (node) => { + const layout = resolveLeanToLayout(node) + return [0, layout.lowEdgeHeight + HEIGHT_HANDLE_OFFSET, node.projection] + }, + }, + measureLabel: 'Projection', +}) +leanToExtensionHandles.push(spanHandle('right'), spanHandle('left')) + +export const leanToExtensionDefinition: NodeDefinition = { + kind: 'lean-to-extension', + schemaVersion: 7, + schema: LeanToExtensionNode, + category: 'structure', + snapProfile: 'structural', + extensions: { + 'pascal:editor/floorplan': { + tool: () => import('./floorplan-tool'), + } satisfies FloorplanNodeExtension, + }, + defaults: () => { + const parsed = LeanToExtensionNode.parse({}) + const { id: _id, type: _type, ...defaults } = parsed + return defaults + }, + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + slots: () => leanToSlots(), + paint: leanToPaint, + }, + relations: { + cascadeDelete: 'descendants', + hosts: ['column', 'roof'], + }, + parametrics: leanToExtensionParametrics, + handles: leanToExtensionHandles, + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + geometry: buildLeanToExtensionGeometry, + geometryKey: leanToExtensionGeometryKey, + system: { + module: () => import('./system'), + priority: 1, + }, + floorplan: buildLeanToExtensionFloorplan, + floorplanMoveTarget: leanToFloorplanMoveTarget, + floorplanAffordances: { 'lean-to-resize': leanToResizeAffordance }, + affordanceTools: { move: () => import('./move-tool') }, + preview: () => import('./preview'), + tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Attach lean-to extension to wall' }, + { key: 'Esc', label: 'Cancel' }, + ], + presentation: { + label: 'Lean-to Extension', + description: 'An open mono-pitch roof attached to a wall and supported by a pillar row.', + icon: { kind: 'url', src: '/icons/lean-to-extension.webp' }, + paletteSection: 'structure', + paletteGroup: 'roof-features', + paletteOrder: 105, + }, + mcp: { + description: + 'A wall-hosted open lean-to canopy composed from a standard shed roof segment, standard gutter and downspout accessories, editable column children, ledger, rafters, and a front beam.', + }, +} diff --git a/packages/nodes/src/lean-to-extension/floorplan-affordances.ts b/packages/nodes/src/lean-to-extension/floorplan-affordances.ts new file mode 100644 index 0000000000..d7f3e50b1f --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan-affordances.ts @@ -0,0 +1,79 @@ +import { + type AnyNodeId, + type FloorplanAffordance, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + type LeanToExtensionNode, + snapScalar, + useLiveNodeOverrides, + type WallNode, +} from '@pascal-app/core' +import { getSegmentGridStep } from '@pascal-app/editor' +import { deriveLeanToResizePatch } from './parametrics' + +type ResizePayload = { dimension: 'projection' | 'span'; side?: 1 | -1 } + +export const leanToResizeAffordance: FloorplanAffordance = { + start({ node, nodes, payload, initialPlanPoint, sceneApi }) { + const wall = node.parentId + ? (nodes[node.parentId as AnyNodeId] as WallNode | undefined) + : undefined + if (wall?.type !== 'wall' || !sceneApi) { + return { affectedIds: [], apply() {}, canCommit: () => false } + } + const { dimension, side = 1 } = payload as ResizePayload + const outwardSign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 + let along: readonly [number, number] + let outward: readonly [number, number] + // On a curved host the drag axes are the wall arc's tangent / normal at + // the lean-to's along-wall position, not the straight chord direction. + if (isCurvedWall(wall)) { + const arcLength = Math.max(1e-6, getWallCurveLength(wall)) + const t = Math.max(0, Math.min(1, node.position[0] / arcLength)) + const frame = getWallCurveFrameAt(wall, t) + along = [frame.tangent.x, frame.tangent.y] + outward = [frame.normal.x * outwardSign, frame.normal.y * outwardSign] + } else { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.max(1e-6, Math.hypot(dx, dz)) + along = [dx / length, dz / length] + outward = [-along[1] * outwardSign, along[0] * outwardSign] + } + const axis = dimension === 'projection' ? outward : along + const initialAxis = initialPlanPoint[0] * axis[0] + initialPlanPoint[1] * axis[1] + const initialValue = dimension === 'projection' ? node.projection : node.span + const initialPosition = node.position + let lastPatch: Partial = {} + + return { + affectedIds: [node.id as AnyNodeId], + apply({ planPoint }) { + const currentAxis = planPoint[0] * axis[0] + planPoint[1] * axis[1] + const raw = initialValue + (currentAxis - initialAxis) * side + const step = getSegmentGridStep() + const value = Math.max(0.5, step > 0 ? snapScalar(raw, step) : raw) + lastPatch = + dimension === 'projection' + ? { projection: value, ...deriveLeanToResizePatch(node, { projection: value }) } + : { + span: value, + autoSpan: false, + position: [ + initialPosition[0] + (side * (value - initialValue)) / 2, + initialPosition[1], + initialPosition[2], + ], + } + useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch) + sceneApi.markDirty(node.id as AnyNodeId) + }, + canCommit: () => Object.keys(lastPatch).length > 0, + commit() { + useLiveNodeOverrides.getState().clear(node.id as AnyNodeId) + sceneApi.update(node.id as AnyNodeId, lastPatch) + }, + } + }, +} diff --git a/packages/nodes/src/lean-to-extension/floorplan-move.ts b/packages/nodes/src/lean-to-extension/floorplan-move.ts new file mode 100644 index 0000000000..2f0af0f115 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan-move.ts @@ -0,0 +1,107 @@ +import { + type AnyNode, + type AnyNodeId, + type FloorplanMoveTarget, + isCurvedWall, + type LeanToExtensionNode, + sampleWallCenterline, + useLiveNodeOverrides, + type WallNode, +} from '@pascal-app/core' +import { getSegmentGridStep } from '@pascal-app/editor' +import { resolveLeanToEdgeSnapTargets, resolveLeanToMoveCenterX } from './layout' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' + +// Arc-length along the wall centerline to the point on it nearest the +// cursor. position[0] is measured as arc-length on a curved host, so the +// straight chord projection would drift the further the cursor is from the +// chord — sample the centerline polyline and walk it instead. +function arcLengthUnderPoint(wall: WallNode, planPoint: readonly [number, number]): number { + const samples = sampleWallCenterline(wall) + let bestDistanceSq = Number.POSITIVE_INFINITY + let bestArcLength = 0 + let accumulated = 0 + for (let i = 0; i < samples.length - 1; i++) { + const a = samples[i]! + const b = samples[i + 1]! + const dx = b.x - a.x + const dz = b.y - a.y + const segLengthSq = dx * dx + dz * dz + const t = + segLengthSq <= 1e-12 + ? 0 + : Math.max( + 0, + Math.min(1, ((planPoint[0] - a.x) * dx + (planPoint[1] - a.y) * dz) / segLengthSq), + ) + const px = a.x + dx * t + const pz = a.y + dz * t + const distanceSq = (planPoint[0] - px) ** 2 + (planPoint[1] - pz) ** 2 + if (distanceSq < bestDistanceSq) { + bestDistanceSq = distanceSq + bestArcLength = accumulated + Math.sqrt(segLengthSq) * t + } + accumulated += Math.sqrt(segLengthSq) + } + return bestArcLength +} + +export const leanToFloorplanMoveTarget: FloorplanMoveTarget = ({ + node, + sceneApi, +}) => { + const nodeId = node.id as AnyNodeId + const wall = node.parentId ? (sceneApi?.get(node.parentId as AnyNodeId) as WallNode) : undefined + let lastPatch: Partial | null = null + + return { + affectedIds: [nodeId], + apply({ planPoint, modifiers }) { + if (wall?.type !== 'wall' || !sceneApi) return + const rawLocalX = isCurvedWall(wall) + ? arcLengthUnderPoint(wall, planPoint) + : (() => { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.max(1e-6, Math.hypot(dx, dz)) + return ( + ((planPoint[0] - wall.start[0]) * dx + (planPoint[1] - wall.start[1]) * dz) / length + ) + })() + const step = modifiers.altKey ? 0 : getSegmentGridStep() + const nodes = sceneApi.nodes() as Record + const position: LeanToExtensionNode['position'] = [ + resolveLeanToMoveCenterX( + node, + wall, + rawLocalX, + step, + modifiers.altKey ? [] : resolveLeanToEdgeSnapTargets(node, wall, nodes), + ), + node.position[1], + node.position[2], + ] + const candidate = resolveLeanToEndAbutments( + { ...node, position, autoSpan: false }, + wall, + nodes, + ) + const patch: Partial = { + position, + autoSpan: false, + leftEndCondition: candidate.leftEndCondition, + rightEndCondition: candidate.rightEndCondition, + downspoutPosition: candidate.downspoutPosition, + } + useLiveNodeOverrides.getState().set(nodeId, patch) + sceneApi.markDirty(nodeId) + lastPatch = leanToPlacementConflicts(candidate, wall, nodes).length === 0 ? patch : null + }, + canCommit: () => lastPatch !== null, + commit() { + if (!(lastPatch && sceneApi)) return + useLiveNodeOverrides.getState().clear(nodeId) + sceneApi.update(nodeId, lastPatch as Partial) + }, + } +} diff --git a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx new file mode 100644 index 0000000000..0030aa6014 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx @@ -0,0 +1,231 @@ +'use client' + +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { getWallCurveFrameAt, getWallCurveLength, isCurvedWall } from '@pascal-app/core' +import { + type FloorplanToolContext, + markToolCancelConsumed, + triggerSFX, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { useCallback, useEffect, useRef, useState } from 'react' +import { findClosestWallInPlan } from '../shared/wall-attach-target' +import { bendLocalPoint, isCurvedLeanTo } from './arc' +import { createLeanToAssembly } from './assembly' +import { leanToFacetCount } from './geometry' +import { resolveLeanToSpanArc, resolveLeanToWallPlacement } from './layout' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' +import { + applyLeanToAvailableWallSpan, + applyLeanToRoofAttachment, + applyLeanToWallAutoSpan, + clearLeanToRoofAttachment, + resolveLeanToHostRoof, + resolveLeanToRoofAttachment, +} from './roof-attachment' +import type { LeanToExtensionNode } from './schema' + +type PlanPoint = [number, number] + +function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number): PlanPoint | null { + const matrix = group.getScreenCTM() + if (!matrix) return null + const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse()) + return [local.x, local.y] +} + +const FloorplanLeanToExtensionTool = ({ + activeLevelId, + finishTool, + sceneApi, + selectNode, +}: FloorplanToolContext) => { + const groupRef = useRef(null) + const targetRef = useRef(null) + const [target, setTarget] = useState(null) + + const clearTarget = useCallback(() => { + targetRef.current = null + setTarget(null) + }, []) + + useEffect(() => { + if (!activeLevelId) return + const group = groupRef.current + const svg = group?.ownerSVGElement + if (!(group && svg)) return + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'lean-to-extension' }) + + const consume = (event: Event) => { + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + } + const resolveEvent = (event: MouseEvent | PointerEvent) => { + const point = clientToPlanPoint(group, event.clientX, event.clientY) + if (!point) return null + const hit = findClosestWallInPlan( + point, + sceneApi.nodes() as Record, + activeLevelId, + ) + if (!hit) return null + const wallPlacement = resolveLeanToWallPlacement(hit.wall, hit.localX, hit.side) + if (!wallPlacement) return null + const nodes = sceneApi.nodes() as Record + const attachment = resolveLeanToRoofAttachment(wallPlacement, hit.wall, nodes) + const autoSpannedNode = attachment + ? applyLeanToRoofAttachment(wallPlacement, attachment) + : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), hit.wall) + const attachedNode = applyLeanToAvailableWallSpan( + autoSpannedNode, + hit.wall, + nodes, + wallPlacement.position[0], + ) + const node = resolveLeanToEndAbutments(attachedNode, hit.wall, nodes) + return leanToPlacementConflicts(node, hit.wall, nodes).length === 0 ? node : null + } + const update = (event: PointerEvent) => { + consume(event) + const node = resolveEvent(event) + targetRef.current = node + setTarget(node) + } + const onPointerDown = (event: PointerEvent) => { + if (event.button === 0) consume(event) + } + const commit = (event: MouseEvent) => { + if (event.button !== 0) return + consume(event) + const node = resolveEvent(event) ?? targetRef.current + if (!node) return + const nodes = sceneApi.nodes() as Record + const assembly = createLeanToAssembly(node, resolveLeanToHostRoof(node, nodes), nodes) + sceneApi.createMany?.([ + { node: assembly.extension, parentId: node.parentId as AnyNodeId }, + ...assembly.children.map((child) => ({ + node: child, + parentId: (child.parentId as AnyNodeId | null) ?? undefined, + })), + ]) + selectNode(assembly.extension.id) + triggerSFX('sfx:structure-build') + if (useEditor.getState().getContinuation('point') !== 'repeat') finishTool() + } + const cancel = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + event.preventDefault() + event.stopImmediatePropagation() + markToolCancelConsumed() + finishTool() + } + + svg.addEventListener('pointerdown', onPointerDown, true) + svg.addEventListener('pointermove', update, true) + svg.addEventListener('pointerleave', clearTarget, true) + svg.addEventListener('click', commit, true) + window.addEventListener('keydown', cancel, true) + return () => { + svg.removeEventListener('pointerdown', onPointerDown, true) + svg.removeEventListener('pointermove', update, true) + svg.removeEventListener('pointerleave', clearTarget, true) + svg.removeEventListener('click', commit, true) + window.removeEventListener('keydown', cancel, true) + clearTarget() + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'drafting' && scope.tool === 'lean-to-extension') + } + }, [activeLevelId, clearTarget, finishTool, sceneApi, selectNode]) + + if (!activeLevelId) return null + const wall = target?.parentId ? sceneApi.get(target.parentId as AnyNodeId) : null + if (!(target && wall?.type === 'wall')) return + + const sign = Math.cos(target.rotation[1]) >= 0 ? 1 : -1 + // Recompute the local arc from the final placed span/position so the preview + // footprint bends the same way reconciliation will store it. + const spanArc = resolveLeanToSpanArc(wall, target) + const previewNode = { + ...target, + spanArcCenterZ: spanArc?.centerZ, + spanArcRadius: spanArc?.radius, + } + const curved = isCurvedLeanTo(previewNode) && isCurvedWall(wall) + + let originX: number + let originZ: number + let alongX: number + let alongZ: number + let perpX: number + let perpZ: number + if (curved) { + const arcLength = getWallCurveLength(wall) + const t = Math.max(0, Math.min(1, arcLength > 1e-6 ? target.position[0] / arcLength : 0)) + const frame = getWallCurveFrameAt(wall, t) + alongX = frame.tangent.x + alongZ = frame.tangent.y + perpX = frame.normal.x + perpZ = frame.normal.y + originX = frame.point.x + perpX * target.position[2] + originZ = frame.point.y + perpZ * target.position[2] + } else { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + alongX = dx / length + alongZ = dz / length + perpX = -alongZ + perpZ = alongX + originX = wall.start[0] + alongX * target.position[0] + perpX * target.position[2] + originZ = wall.start[1] + alongZ * target.position[0] + perpZ * target.position[2] + } + const localAlongX = alongX * sign + const localAlongZ = alongZ * sign + const outX = perpX * sign + const outZ = perpZ * sign + const toWorld = (localX: number, localZ: number): [number, number] => { + if (curved) { + const bent = bendLocalPoint(previewNode, localX, localZ) + return [ + originX + localAlongX * bent.x + outX * bent.y, + originZ + localAlongZ * bent.x + outZ * bent.y, + ] + } + return [ + originX + localAlongX * localX + outX * localZ, + originZ + localAlongZ * localX + outZ * localZ, + ] + } + const left = target.span / 2 + target.leftOverhang + const right = target.span / 2 + target.rightOverhang + const high = target.highOverhang + const low = target.projection + target.lowOverhang + const facets = curved ? leanToFacetCount(previewNode) : 1 + const highEdge: [number, number][] = [] + const lowEdge: [number, number][] = [] + for (let i = 0; i <= facets; i++) { + const localX = -left + ((right + left) * i) / facets + highEdge.push(toWorld(localX, -high)) + lowEdge.push(toWorld(localX, low)) + } + const points = [...highEdge, ...lowEdge.reverse()] + + return ( + + point.join(',')).join(' ')} + stroke="#0ea5e9" + strokeDasharray="6 4" + strokeWidth={2} + vectorEffect="non-scaling-stroke" + /> + + ) +} + +export default FloorplanLeanToExtensionTool diff --git a/packages/nodes/src/lean-to-extension/floorplan.test.ts b/packages/nodes/src/lean-to-extension/floorplan.test.ts new file mode 100644 index 0000000000..80c1073851 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import { + type GeometryContext, + getWallCurveFrameAt, + getWallCurveLength, + WallNode, +} from '@pascal-app/core' +import { buildLeanToExtensionFloorplan } from './floorplan' +import { resolveLeanToWallPlacement } from './layout' + +describe('curved lean-to floorplan', () => { + test('matches the committed back-side frame direction', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], curveOffset: 1, thickness: 0.2 }) + const wallLength = getWallCurveLength(wall) + const node = resolveLeanToWallPlacement(wall, wallLength / 2, 'back', { + span: 1, + projection: 1, + highOverhang: 0, + lowOverhang: 0, + leftOverhang: 0, + rightOverhang: 0, + })! + const geometry = buildLeanToExtensionFloorplan(node, { + children: [], + parent: wall, + resolve: () => undefined, + siblings: [], + } as GeometryContext) + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + const roof = geometry.children.find((child) => child.kind === 'polygon') + expect(roof?.kind).toBe('polygon') + if (roof?.kind !== 'polygon') return + + // On the back face, local -X points toward increasing centerline arc length. + const frame = getWallCurveFrameAt(wall, (node.position[0] + node.span / 2) / wallLength) + expect(roof.points[0]?.[0]).toBeCloseTo(frame.point.x + frame.normal.x * node.position[2], 3) + expect(roof.points[0]?.[1]).toBeCloseTo(frame.point.y + frame.normal.y * node.position[2], 3) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/floorplan.ts b/packages/nodes/src/lean-to-extension/floorplan.ts new file mode 100644 index 0000000000..b56f4c814f --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan.ts @@ -0,0 +1,166 @@ +import { + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + type LeanToExtensionNode, + type WallNode, +} from '@pascal-app/core' +import { bendLocalPoint, isCurvedLeanTo } from './arc' +import { leanToFacetCount } from './geometry' +import { resolveLeanToLayout } from './layout' + +export function buildLeanToExtensionFloorplan( + node: LeanToExtensionNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const wall = ctx.parent as WallNode | null + if (wall?.type !== 'wall') return null + + const outwardSign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 + const layout = resolveLeanToLayout(node) + const curved = isCurvedLeanTo(node) && isCurvedWall(wall) + + // Rigid placement basis: the node's origin on the wall plus the along + // (tangent) and outward (normal) axes. The straight case reads the wall + // chord; the curved case reads the wall arc frame at the node's + // along-wall position. Local geometry is then bent in local space and + // mapped through this single pose — mirroring the 3D group transform. + let originX: number + let originZ: number + let alongX: number + let alongZ: number + let perpX: number + let perpZ: number + if (curved) { + const arcLength = getWallCurveLength(wall) + if (arcLength <= 1e-6) return null + const t = Math.max(0, Math.min(1, node.position[0] / arcLength)) + const frame = getWallCurveFrameAt(wall, t) + alongX = frame.tangent.x + alongZ = frame.tangent.y + perpX = frame.normal.x + perpZ = frame.normal.y + originX = frame.point.x + perpX * node.position[2] + originZ = frame.point.y + perpZ * node.position[2] + } else { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length < 1e-6) return null + alongX = dx / length + alongZ = dz / length + perpX = -alongZ + perpZ = alongX + originX = wall.start[0] + alongX * node.position[0] + perpX * node.position[2] + originZ = wall.start[1] + alongZ * node.position[0] + perpZ * node.position[2] + } + const localAlongX = alongX * outwardSign + const localAlongZ = alongZ * outwardSign + const outX = perpX * outwardSign + const outZ = perpZ * outwardSign + + const toWorld = (localX: number, localZ: number): FloorplanPoint => { + if (curved) { + const bent = bendLocalPoint(node, localX, localZ) + return [ + originX + localAlongX * bent.x + outX * bent.y, + originZ + localAlongZ * bent.x + outZ * bent.y, + ] + } + return [ + originX + localAlongX * localX + outX * localZ, + originZ + localAlongZ * localX + outZ * localZ, + ] + } + + const left = layout.span / 2 + node.leftOverhang + const right = layout.span / 2 + node.rightOverhang + const high = node.highOverhang + const low = layout.projection + node.lowOverhang + + const facets = curved ? leanToFacetCount(node) : 1 + const highEdge: FloorplanPoint[] = [] + const lowEdge: FloorplanPoint[] = [] + for (let i = 0; i <= facets; i++) { + const localX = -left + ((right + left) * i) / facets + highEdge.push(toWorld(localX, -high)) + lowEdge.push(toWorld(localX, low)) + } + const points: readonly FloorplanPoint[] = [...highEdge, ...lowEdge.reverse()] + + const selected = ctx.viewState?.selected ?? false + const stroke = selected ? '#f97316' : '#475569' + const children: FloorplanGeometry[] = [ + { + kind: 'polygon', + points, + fill: selected ? '#ffedd5' : '#e2e8f0', + fillOpacity: 0.65, + stroke, + strokeWidth: selected ? 2 : 1.25, + vectorEffect: 'non-scaling-stroke', + }, + ] + + const beamPoints: FloorplanPoint[] = [] + for (let i = 0; i <= facets; i++) { + const localX = -layout.span / 2 + (layout.span * i) / facets + beamPoints.push(toWorld(localX, layout.beamZ)) + } + children.push({ + kind: 'polyline', + points: beamPoints, + stroke, + strokeWidth: selected ? 3 : 2, + vectorEffect: 'non-scaling-stroke', + }) + + for (const x of layout.postXs) { + const [postX, postZ] = toWorld(x, layout.beamZ) + children.push({ + kind: 'rect', + x: postX - node.postWidth / 2, + y: postZ - node.postDepth / 2, + width: node.postWidth, + height: node.postDepth, + fill: stroke, + stroke, + strokeWidth: 1, + vectorEffect: 'non-scaling-stroke', + }) + } + + if (selected) { + const arrowOffset = 0.12 + const [eaveX, eaveZ] = toWorld(0, layout.roofRun + arrowOffset) + children.push({ + kind: 'move-arrow', + point: [eaveX, eaveZ], + angle: Math.atan2(outZ, outX), + affordance: 'lean-to-resize', + payload: { dimension: 'projection' }, + }) + for (const side of [-1, 1] as const) { + const x = + side < 0 + ? -(layout.span / 2 + node.leftOverhang + arrowOffset) + : layout.span / 2 + node.rightOverhang + arrowOffset + const point = toWorld(x, layout.beamZ) + // Local tangent at the arrow, mapped to world, so the span arrow + // points along the (possibly bent) eave rather than the chord. + const ahead = toWorld(x + side * 0.01, layout.beamZ) + children.push({ + kind: 'move-arrow', + point, + angle: Math.atan2(ahead[1] - point[1], ahead[0] - point[0]), + affordance: 'lean-to-resize', + payload: { dimension: 'span', side }, + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/lean-to-extension/geometry.test.ts b/packages/nodes/src/lean-to-extension/geometry.test.ts new file mode 100644 index 0000000000..faa90a25cb --- /dev/null +++ b/packages/nodes/src/lean-to-extension/geometry.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, test } from 'bun:test' +import { LeanToExtensionNode } from '@pascal-app/core' +import { resolveSurfaceColor } from '@pascal-app/viewer' +import { Box3, type BoxGeometry, type Mesh, type MeshStandardMaterial, Vector3 } from 'three' +import { buildGutterGeometry } from '../gutter/geometry' +import { createLeanToAssembly } from './assembly' +import { buildLeanToExtensionGeometry } from './geometry' +import { resolveLeanToLayout } from './layout' +import { leanToSlots } from './slots' + +describe('lean-to extension geometry', () => { + test('defaults structural framing to the untextured wall role color', () => { + const defaults = Object.fromEntries(leanToSlots().map((slot) => [slot.slotId, slot.default])) + const group = buildLeanToExtensionGeometry(LeanToExtensionNode.parse({})) + + expect(defaults.ledger).toBeUndefined() + expect(defaults.beam).toBeUndefined() + expect(defaults.framing).toBeUndefined() + for (const name of ['lean-to-front-beam', 'lean-to-rafter-0']) { + const material = (group.getObjectByName(name) as Mesh).material as MeshStandardMaterial + expect(material.color.getHexString()).toBe(resolveSurfaceColor('wall', 'clay').slice(1)) + expect(material.map).toBeFalsy() + } + }) + + test('builds a placement preview with structure and a roof proxy', () => { + const node = LeanToExtensionNode.parse({ postCount: 3, span: 4 }) + const group = buildLeanToExtensionGeometry(node) + const names = group.children.map((child) => child.name) + expect(names).toContain('lean-to-preview-roof') + expect(names).not.toContain('lean-to-ledger') + expect(names).toContain('lean-to-front-beam') + expect(names).not.toContain('lean-to-high-side-flashing') + expect(names.some((name) => name.includes('gutter'))).toBe(false) + expect(names.some((name) => name.includes('downspout'))).toBe(false) + expect(names.filter((name) => name.startsWith('lean-to-post-'))).toHaveLength(3) + expect( + names.filter((name) => name.startsWith('lean-to-rafter-')).length, + ).toBeGreaterThanOrEqual(3) + }) + + test('models side flashing for abutting ends only', () => { + const node = LeanToExtensionNode.parse({ + leftEndCondition: 'wall-abutment', + rightEndCondition: 'open', + }) + const group = buildLeanToExtensionGeometry(node) + expect(group.getObjectByName('lean-to-left-side-flashing')).toBeDefined() + expect(group.getObjectByName('lean-to-right-side-flashing')).toBeUndefined() + }) + + test('uses configurable side flashing dimensions', () => { + const node = LeanToExtensionNode.parse({ + sideFlashing: true, + leftEndCondition: 'wall-abutment', + flashingHeight: 0.22, + flashingProjection: 0.06, + }) + const group = buildLeanToExtensionGeometry(node) + const flashing = group.getObjectByName('lean-to-left-side-flashing') as Mesh + const parameters = flashing.geometry.parameters as { width: number; height: number } + + expect(parameters.height).toBeCloseTo(0.22) + expect(parameters.width).toBeCloseTo(0.06) + }) + + test('switches between hidden, rafter, and purlin framing', () => { + const hiddenNames = buildLeanToExtensionGeometry( + LeanToExtensionNode.parse({ framingStrategy: 'hidden' }), + ).children.map((child) => child.name) + const purlinNames = buildLeanToExtensionGeometry( + LeanToExtensionNode.parse({ framingStrategy: 'purlins' }), + ).children.map((child) => child.name) + + expect(hiddenNames.some((name) => name.startsWith('lean-to-rafter-'))).toBe(false) + expect(hiddenNames.some((name) => name.startsWith('lean-to-purlin-'))).toBe(false) + expect(purlinNames.some((name) => name.startsWith('lean-to-purlin-'))).toBe(true) + expect(purlinNames.some((name) => name.startsWith('lean-to-rafter-'))).toBe(false) + }) + + test('models an independent high beam and tags configurable finish slots', () => { + const node = LeanToExtensionNode.parse({ highSideMode: 'independent-high-beam' }) + const group = buildLeanToExtensionGeometry(node) + expect(group.getObjectByName('lean-to-independent-high-beam')).toBeDefined() + expect(group.getObjectByName('lean-to-high-post-0')).toBeDefined() + expect(group.getObjectByName('lean-to-high-side-flashing')).toBeUndefined() + expect(group.getObjectByName('lean-to-front-beam')?.userData.slotId).toBe('beam') + }) + + test('leaves the roof and posts to real child nodes in scene geometry', () => { + const node = LeanToExtensionNode.parse({ postCount: 3 }) + const group = buildLeanToExtensionGeometry(node, {} as never) + expect( + group.children.map((child) => child.name).filter((name) => name.startsWith('lean-to-post-')), + ).toEqual([]) + expect(group.children.map((child) => child.name)).not.toContain('lean-to-preview-roof') + }) + + test('extends connected roof framing to the wall without a full-width infill panel', () => { + const disconnected = LeanToExtensionNode.parse({ projection: 2.5 }) + const connected = LeanToExtensionNode.parse({ projection: 2.5, connectionInset: 0.3 }) + const disconnectedGroup = buildLeanToExtensionGeometry(disconnected) + const connectedGroup = buildLeanToExtensionGeometry(connected) + const depth = (group: ReturnType, name: string) => + ((group.getObjectByName(name) as Mesh).geometry.parameters as { depth: number }) + .depth + + expect(depth(connectedGroup, 'lean-to-preview-roof')).toBeCloseTo( + depth(disconnectedGroup, 'lean-to-preview-roof'), + ) + expect(depth(connectedGroup, 'lean-to-rafter-0')).toBeCloseTo( + depth(disconnectedGroup, 'lean-to-rafter-0'), + ) + expect(connectedGroup.getObjectByName('lean-to-connection-underlap')).toBeUndefined() + expect(disconnectedGroup.getObjectByName('lean-to-connection-underlap')).toBeUndefined() + }) + + test('continues rafters over the front beam with a small gutter clearance', () => { + const node = LeanToExtensionNode.parse({ projection: 2.5, lowOverhang: 0.25 }) + const group = buildLeanToExtensionGeometry(node, {} as never) + const rafter = group.getObjectByName('lean-to-rafter-0') as Mesh + const rafterSlopeLength = (rafter.geometry.parameters as { depth: number }).depth + const rafterFrontZ = rafter.position.z + (rafterSlopeLength * Math.cos(rafter.rotation.x)) / 2 + const beamOuterZ = node.projection + node.beamWidth / 2 + const assembly = createLeanToAssembly(node) + const gutterGeometry = buildGutterGeometry(assembly.gutter) + gutterGeometry.computeBoundingBox() + group.updateMatrixWorld(true) + const rafterBounds = new Box3().setFromObject(rafter) + const gutterBackZ = + assembly.segment.position[2] + + assembly.gutter.position[2] + + (gutterGeometry.boundingBox?.min.z ?? 0) + const gutterClearance = gutterBackZ - rafterBounds.max.z + + expect(rafterFrontZ).toBeGreaterThan(beamOuterZ) + expect(gutterClearance).toBeGreaterThan(0) + expect(gutterClearance).toBeCloseTo(0.033, 5) + gutterGeometry.dispose() + }) + + test('still carries rafters across the front beam when there is no eave overhang', () => { + const node = LeanToExtensionNode.parse({ projection: 2.5, lowOverhang: 0 }) + const group = buildLeanToExtensionGeometry(node, {} as never) + const rafter = group.getObjectByName('lean-to-rafter-0') as Mesh + const rafterSlopeLength = (rafter.geometry.parameters as { depth: number }).depth + const rafterFrontZ = rafter.position.z + (rafterSlopeLength * Math.cos(rafter.rotation.x)) / 2 + + expect(rafterFrontZ).toBeCloseTo(node.projection + node.beamWidth / 2, 6) + }) + + test('ends the front beam flush with the outside faces of the end pillars', () => { + const node = LeanToExtensionNode.parse({ span: 4, postCount: 3, postInset: 0.2 }) + const group = buildLeanToExtensionGeometry(node) + const beam = group.getObjectByName('lean-to-front-beam') as Mesh + const firstPost = group.getObjectByName('lean-to-post-0') as Mesh + const beamWidth = (beam.geometry.parameters as { width: number }).width + const postWidth = (firstPost.geometry.parameters as { width: number }).width + const beamMinX = beam.position.x - beamWidth / 2 + const firstPostMinX = firstPost.position.x - postWidth / 2 + + expect(beamMinX).toBeCloseTo(firstPostMinX, 6) + }) + + test('joins curved front-beam facets at the pillar tops on both wall sides', () => { + for (const spanArcCenterZ of [-5, 5]) { + const node = LeanToExtensionNode.parse({ + span: 8, + projection: 2, + postCount: 5, + spanArcCenterZ, + spanArcRadius: 5, + }) + const group = buildLeanToExtensionGeometry(node) + const facets = group.children + .filter((child): child is Mesh => child.name.startsWith('lean-to-front-beam-')) + .sort( + (a, b) => + Number(a.name.slice(a.name.lastIndexOf('-') + 1)) - + Number(b.name.slice(b.name.lastIndexOf('-') + 1)), + ) + + group.updateMatrixWorld(true) + let maximumJointGap = 0 + for (let index = 0; index + 1 < facets.length; index++) { + const current = facets[index]! + const next = facets[index + 1]! + const currentWidth = (current.geometry.parameters as { width: number }).width + const nextWidth = (next.geometry.parameters as { width: number }).width + const currentEnd = current.localToWorld(new Vector3(currentWidth / 2, 0, 0)) + const nextStart = next.localToWorld(new Vector3(-nextWidth / 2, 0, 0)) + maximumJointGap = Math.max(maximumJointGap, currentEnd.distanceTo(nextStart)) + } + + const firstPost = group.getObjectByName('lean-to-post-0') as Mesh + const postHeight = (firstPost.geometry.parameters as { height: number }).height + const beamHeight = (facets[0]!.geometry.parameters as { height: number }).height + const postTop = firstPost.position.y + postHeight / 2 + const beamBottom = facets[0]!.position.y - beamHeight / 2 + + expect(facets.length).toBeGreaterThan(1) + expect(group.getObjectByName('lean-to-front-beam')).toBeUndefined() + expect(maximumJointGap).toBeLessThan(0.01) + expect(beamBottom).toBeCloseTo(postTop, 6) + } + }) + + test('replaces the joined boundary rafter and extends the beam to the shared corner post', () => { + const node = LeanToExtensionNode.parse({ + span: 4, + rightEndCondition: 'joined', + metadata: { + leanToCornerJoints: { + right: { + beamExtension: 2.5, + gutterMitre: Math.PI / 4, + seam: [ + [2, 0], + [4.5, 2.5], + ], + sharedPostOwner: true, + }, + }, + }, + }) + const layout = resolveLeanToLayout(node) + const group = buildLeanToExtensionGeometry(node, {} as never) + const beam = group.getObjectByName('lean-to-front-beam') as Mesh + const beamWidth = (beam.geometry.parameters as { width: number }).width + const beamPositions = beam.geometry.getAttribute('position') + const rightEndXs = Array.from({ length: beamPositions.count }, (_, index) => + beamPositions.getX(index), + ).filter((x) => x > beamWidth / 2 - node.beamWidth * 1.1) + const ordinaryRafters = group.children.filter((child) => + child.name.startsWith('lean-to-rafter-'), + ) + + expect(beamWidth).toBeCloseTo(layout.beamSpan + 2.5, 6) + expect(beam.position.x).toBeCloseTo(1.25, 6) + expect(Math.max(...rightEndXs) - Math.min(...rightEndXs)).toBeCloseTo(node.beamWidth, 6) + expect(ordinaryRafters).toHaveLength(layout.rafterXs.length - 1) + expect(group.getObjectByName('lean-to-right-corner-rafter')).toBeDefined() + expect(group.getObjectByName('lean-to-right-side-flashing')).toBeUndefined() + }) + + test('clips ordinary rafters at a concave valley seam', () => { + const seam = [ + [2, 0], + [-0.75, 2.75], + ] as const + const node = LeanToExtensionNode.parse({ + span: 4, + leftOverhang: 0, + rightOverhang: 0, + metadata: { + leanToCornerJoints: { + right: { + beamExtension: -2.5, + gutterMitre: -Math.PI / 4, + seam, + sharedPostOwner: true, + }, + }, + }, + }) + const group = buildLeanToExtensionGeometry(node, {} as never) + group.updateMatrixWorld(true) + const ordinaryRafters = group.children.filter((child): child is Mesh => + child.name.startsWith('lean-to-rafter-'), + ) + const seamMinX = Math.min(seam[0][0], seam[1][0]) + const seamMaxX = Math.max(seam[0][0], seam[1][0]) + + for (const rafter of ordinaryRafters) { + if (rafter.position.x < seamMinX || rafter.position.x > seamMaxX) continue + const ratio = (rafter.position.x - seam[0][0]) / (seam[1][0] - seam[0][0]) + const seamZ = seam[0][1] + (seam[1][1] - seam[0][1]) * ratio + const bounds = new Box3().setFromObject(rafter) + expect(bounds.max.z).toBeLessThanOrEqual(seamZ + 1e-6) + } + }) + + test('clips purlins and removes knee braces beyond a concave beam', () => { + const seam = [ + [2, 0], + [-0.75, 2.75], + ] as const + const node = LeanToExtensionNode.parse({ + span: 4, + leftOverhang: 0, + rightOverhang: 0, + framingStrategy: 'purlins', + postBracing: 'knee', + metadata: { + leanToCornerJoints: { + right: { + beamExtension: -2.5, + gutterMitre: -Math.PI / 4, + seam, + sharedPostOwner: true, + }, + }, + }, + }) + const group = buildLeanToExtensionGeometry(node, {} as never) + const braces = group.children.filter((child) => child.name.startsWith('lean-to-knee-brace-')) + const purlins = group.children.filter((child): child is Mesh => + child.name.startsWith('lean-to-purlin-'), + ) + + expect(braces).toHaveLength(1) + for (const purlin of purlins) { + const ratio = (purlin.position.z - seam[0][1]) / (seam[1][1] - seam[0][1]) + if (ratio < 0 || ratio > 1) continue + const seamX = seam[0][0] + (seam[1][0] - seam[0][0]) * ratio + const width = (purlin.geometry.parameters as { width: number }).width + expect(purlin.position.x + width / 2).toBeLessThanOrEqual(seamX + 1e-6) + } + }) + + test('cuts an extended corner beam at the resolved arbitrary mitre angle', () => { + const node = LeanToExtensionNode.parse({ + span: 4, + rightEndCondition: 'joined', + metadata: { + leanToCornerJoints: { + right: { + beamExtension: 2.5, + gutterMitre: Math.PI / 3, + seam: null, + sharedPostOwner: true, + }, + }, + }, + }) + const beam = buildLeanToExtensionGeometry(node, {} as never).getObjectByName( + 'lean-to-front-beam', + ) as Mesh + const positions = beam.geometry.getAttribute('position') + const halfLength = (beam.geometry.parameters as { width: number }).width / 2 + const endXs = Array.from({ length: positions.count }, (_, index) => + positions.getX(index), + ).filter((x) => x > halfLength - node.beamWidth * 2) + + expect(Math.max(...endXs) - Math.min(...endXs)).toBeCloseTo( + node.beamWidth * Math.tan(Math.PI / 3), + 6, + ) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/geometry.ts b/packages/nodes/src/lean-to-extension/geometry.ts new file mode 100644 index 0000000000..1888d64dfd --- /dev/null +++ b/packages/nodes/src/lean-to-extension/geometry.ts @@ -0,0 +1,665 @@ +import type { GeometryContext, LeanToExtensionNode, SurfaceRole } from '@pascal-app/core' +import { + applyWorldScaleBoxUVs, + type ColorPreset, + createSurfaceRoleMaterial, + type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, +} from '@pascal-app/viewer' +import { BoxGeometry, FrontSide, Group, type Material, Mesh, Quaternion, Vector3 } from 'three' +import { bendLocalPoint, bendRotationYAtLocalX, isCurvedLeanTo } from './arc' +import { readLeanToCornerJointMetadata } from './corner-joint' +import { LEAN_TO_EXTENSION_GEOMETRY_REVISION, resolveLeanToLayout } from './layout' +import { LEAN_TO_SLOT_DEFAULTS, type LeanToSlotId } from './slots' + +// Number of straight facets used to approximate a curved member spanning the arc. +export function leanToFacetCount(node: LeanToExtensionNode): number { + if (!isCurvedLeanTo(node)) return 1 + return Math.max(4, Math.min(32, Math.ceil(node.span / 0.4))) +} + +export function leanToExtensionGeometryKey(node: LeanToExtensionNode): string { + return JSON.stringify([ + LEAN_TO_EXTENSION_GEOMETRY_REVISION, + node.span, + node.spanArcCenterZ, + node.spanArcRadius, + node.projection, + node.highEdgeHeight, + node.pitch, + node.roofThickness, + node.highOverhang, + node.lowOverhang, + node.leftOverhang, + node.rightOverhang, + node.coveringType, + node.beamWidth, + node.beamHeight, + node.ledgerDepth, + node.ledgerHeight, + node.highSideMode, + node.ledgerVerticalOffset, + node.lowBeamInset, + node.rafterWidth, + node.rafterHeight, + node.rafterSpacing, + node.rafterEndInset, + node.postWidth, + node.postDepth, + node.postCount, + node.postLayoutMode, + node.postSpacing, + node.postInset, + node.postBracing, + node.footingStyle, + node.sideFlashing, + node.flashingProjection, + node.flashingHeight, + node.slots, + node.framingStrategy, + node.purlinWidth, + node.purlinHeight, + node.purlinSpacing, + node.leftEndCondition, + node.rightEndCondition, + readLeanToCornerJointMetadata(node), + ]) +} + +function addBox( + group: Group, + args: { + name: string + size: [number, number, number] + position: [number, number, number] + rotationX?: number + rotationY?: number + role: SurfaceRole + colorPreset: ColorPreset + sceneTheme?: string + material?: Material + slotId?: LeanToSlotId + }, +) { + const geometry = new BoxGeometry(...args.size) + applyWorldScaleBoxUVs(geometry, ...args.size) + const mesh = new Mesh( + geometry, + args.material ?? + createSurfaceRoleMaterial(args.role, args.colorPreset, FrontSide, args.sceneTheme), + ) + mesh.name = args.name + mesh.position.set(...args.position) + // YXZ: apply pitch (X) first, then yaw (Y) around vertical to face along the arc. + mesh.rotation.set(args.rotationX ?? 0, args.rotationY ?? 0, 0, 'YXZ') + mesh.castShadow = true + mesh.receiveShadow = true + mesh.userData.surfaceRole = args.role + if (args.slotId) mesh.userData.slotId = args.slotId + group.add(mesh) +} + +function addBoxBetween( + group: Group, + args: { + name: string + start: [number, number, number] + end: [number, number, number] + width: number + height: number + role: SurfaceRole + colorPreset: ColorPreset + sceneTheme?: string + material: Material + slotId: LeanToSlotId + }, +) { + const start = new Vector3(...args.start) + const end = new Vector3(...args.end) + const direction = end.clone().sub(start) + const length = direction.length() + if (length <= 1e-6) return + const geometry = new BoxGeometry(args.width, args.height, length) + applyWorldScaleBoxUVs(geometry, args.width, args.height, length) + const mesh = new Mesh(geometry, args.material) + mesh.name = args.name + mesh.position.copy(start.add(end).multiplyScalar(0.5)) + mesh.quaternion.copy( + new Quaternion().setFromUnitVectors(new Vector3(0, 0, 1), direction.normalize()), + ) + mesh.castShadow = true + mesh.receiveShadow = true + mesh.userData.surfaceRole = args.role + mesh.userData.slotId = args.slotId + group.add(mesh) +} + +function addMiteredBeam( + group: Group, + args: { + minX: number + maxX: number + leftMiterCenter: number | null + rightMiterCenter: number | null + leftMiterSlope: number + rightMiterSlope: number + height: number + depth: number + y: number + z: number + colorPreset: ColorPreset + sceneTheme?: string + material: Material + }, +) { + const length = args.maxX - args.minX + if (length <= 1e-6) return + const centerX = (args.minX + args.maxX) / 2 + const halfLength = length / 2 + const geometry = new BoxGeometry(length, args.height, args.depth) + applyWorldScaleBoxUVs(geometry, length, args.height, args.depth) + const positions = geometry.getAttribute('position') + for (let index = 0; index < positions.count; index++) { + const x = positions.getX(index) + const z = positions.getZ(index) + if (args.leftMiterCenter !== null && Math.abs(x + halfLength) <= 1e-6) { + positions.setX(index, args.leftMiterCenter - centerX - z * args.leftMiterSlope) + } else if (args.rightMiterCenter !== null && Math.abs(x - halfLength) <= 1e-6) { + positions.setX(index, args.rightMiterCenter - centerX + z * args.rightMiterSlope) + } + } + positions.needsUpdate = true + geometry.computeVertexNormals() + const mesh = new Mesh(geometry, args.material) + mesh.name = 'lean-to-front-beam' + mesh.position.set(centerX, args.y, args.z) + mesh.castShadow = true + mesh.receiveShadow = true + mesh.userData.surfaceRole = 'joinery' + mesh.userData.slotId = 'beam' + group.add(mesh) +} + +function resolveLeanToSlotMaterial( + node: LeanToExtensionNode, + slotId: LeanToSlotId, + ctx: GeometryContext | undefined, + shading: RenderShading, + textures: boolean, + role: SurfaceRole, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): Material { + if (!textures) return createSurfaceRoleMaterial(role, colorPreset, FrontSide, sceneTheme) + const ref = node.slots?.[slotId] + const slotDefault = LEAN_TO_SLOT_DEFAULTS[slotId] + return ( + (ref ? resolveMaterialRef(ref, ctx?.materials, shading) : null) ?? + (slotDefault + ? resolveSlotDefaultMaterial(slotDefault, shading) + : createSurfaceRoleMaterial(role, colorPreset, FrontSide, sceneTheme)) + ) +} + +export function buildLeanToExtensionGeometry( + node: LeanToExtensionNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, +): Group { + const layout = resolveLeanToLayout(node) + const cornerJoints = readLeanToCornerJointMetadata(node) + const group = new Group() + group.name = 'lean-to-extension-geometry' + + const isConcave = (side: 'left' | 'right') => (cornerJoints[side]?.beamExtension ?? 0) < -1e-6 + const concaveBeamBoundaryX = (side: 'left' | 'right') => { + const extension = cornerJoints[side]?.beamExtension ?? 0 + return side === 'left' ? -layout.span / 2 - extension : layout.span / 2 + extension + } + const isRetainedLowPostX = (x: number) => { + if (isConcave('left') && x <= concaveBeamBoundaryX('left') + 1e-6) return false + if (isConcave('right') && x >= concaveBeamBoundaryX('right') - 1e-6) return false + return true + } + const seamIntersectionAtX = (side: 'left' | 'right', x: number) => { + const seam = cornerJoints[side]?.seam + if (!isConcave(side) || !seam) return null + const [start, end] = seam + const deltaX = end[0] - start[0] + if (Math.abs(deltaX) <= 1e-6) return null + const ratio = (x - start[0]) / deltaX + if (ratio < -1e-6 || ratio > 1 + 1e-6) return null + return { + z: start[1] + (end[1] - start[1]) * ratio, + dzDx: (end[1] - start[1]) / deltaX, + } + } + const retainedWidthAtZ = (z: number) => { + let minX = layout.roofCenterX - layout.roofWidth / 2 + let maxX = layout.roofCenterX + layout.roofWidth / 2 + for (const side of ['left', 'right'] as const) { + const seam = cornerJoints[side]?.seam + if (!isConcave(side) || !seam) continue + const [start, end] = seam + const deltaZ = end[1] - start[1] + if (Math.abs(deltaZ) <= 1e-6) continue + const ratio = (z - start[1]) / deltaZ + if (ratio < -1e-6 || ratio > 1 + 1e-6) continue + const seamX = start[0] + (end[0] - start[0]) * ratio + if (side === 'left') minX = Math.max(minX, seamX) + else maxX = Math.min(maxX, seamX) + } + return { minX, maxX } + } + + const curved = isCurvedLeanTo(node) + const facets = leanToFacetCount(node) + const bend = (localX: number, localZ: number): [number, number] => { + const point = bendLocalPoint(node, localX, localZ) + return [point.x, point.y] + } + const bendRotY = (localX: number) => bendRotationYAtLocalX(node, localX) + // Point-like member (post, rafter, brace): placed on the arc with a per-member yaw. + const addBentBox = (args: { + name: string + size: [number, number, number] + localX: number + localZ: number + y: number + rotationX?: number + role: SurfaceRole + material?: Material + slotId?: LeanToSlotId + }) => { + const [x, z] = bend(args.localX, args.localZ) + addBox(group, { + name: args.name, + size: args.size, + position: [x, args.y, z], + rotationX: args.rotationX, + rotationY: bendRotY(args.localX), + role: args.role, + colorPreset, + sceneTheme, + material: args.material, + slotId: args.slotId, + }) + } + // Width-spanning member (roof strip, purlin, high beam): faceted along the arc. + const addBentStrip = (args: { + name: string + centerX: number + totalWidth: number + height: number + depth: number + localZ: number + y: number + rotationX?: number + role: SurfaceRole + material?: Material + slotId?: LeanToSlotId + }) => { + const count = curved ? facets : 1 + const localFacetWidth = args.totalWidth / count + const facetWidth = curved + ? 2 * + Math.abs((node.spanArcCenterZ ?? 0) - args.localZ) * + Math.tan(localFacetWidth / (2 * (node.spanArcRadius ?? 1))) + : localFacetWidth + for (let index = 0; index < count; index++) { + const centerX = args.centerX - args.totalWidth / 2 + (index + 0.5) * localFacetWidth + const [x, z] = bend(centerX, args.localZ) + addBox(group, { + name: count > 1 ? `${args.name}-${index}` : args.name, + size: [facetWidth + (count > 1 ? 0.004 : 0), args.height, args.depth], + position: [x, args.y, z], + rotationX: args.rotationX, + rotationY: bendRotY(centerX), + role: args.role, + colorPreset, + sceneTheme, + material: args.material, + slotId: args.slotId, + }) + } + } + const flashingMaterial = resolveLeanToSlotMaterial( + node, + 'flashing', + ctx, + shading, + textures, + 'roof', + colorPreset, + sceneTheme, + ) + const ledgerMaterial = resolveLeanToSlotMaterial( + node, + 'ledger', + ctx, + shading, + textures, + 'wall', + colorPreset, + sceneTheme, + ) + const beamMaterial = resolveLeanToSlotMaterial( + node, + 'beam', + ctx, + shading, + textures, + 'wall', + colorPreset, + sceneTheme, + ) + const framingMaterial = resolveLeanToSlotMaterial( + node, + 'framing', + ctx, + shading, + textures, + 'wall', + colorPreset, + sceneTheme, + ) + const postsMaterial = resolveLeanToSlotMaterial( + node, + 'posts', + ctx, + shading, + textures, + 'joinery', + colorPreset, + sceneTheme, + ) + const footingsMaterial = resolveLeanToSlotMaterial( + node, + 'footings', + ctx, + shading, + textures, + 'joinery', + colorPreset, + sceneTheme, + ) + const footingHeight = node.footingStyle === 'concrete-pad' ? 0.12 : 0.04 + const footingScale = node.footingStyle === 'concrete-pad' ? 2 : 1.4 + + if (!ctx) { + addBentStrip({ + name: 'lean-to-preview-roof', + centerX: layout.roofCenterX, + totalWidth: layout.roofWidth, + height: node.roofThickness, + depth: layout.slopeLength, + localZ: layout.roofCenterZ, + y: layout.roofCenterY, + rotationX: layout.pitchRadians, + role: 'roof', + }) + } + + if (node.highSideMode === 'independent-high-beam') { + addBentStrip({ + name: 'lean-to-independent-high-beam', + centerX: 0, + totalWidth: layout.span, + height: node.ledgerHeight, + depth: node.ledgerDepth, + localZ: 0, + y: + layout.highEdgeHeight - + node.roofThickness / 2 - + node.ledgerHeight / 2 + + node.ledgerVerticalOffset, + role: 'joinery', + material: ledgerMaterial, + slotId: 'ledger', + }) + } + + if (node.sideFlashing) { + for (const [side, condition] of [ + [-1, node.leftEndCondition], + [1, node.rightEndCondition], + ] as const) { + if (condition !== 'wall-abutment') continue + addBentBox({ + name: `lean-to-${side < 0 ? 'left' : 'right'}-side-flashing`, + size: [node.flashingProjection, node.flashingHeight, layout.slopeLength], + localX: + side < 0 ? -(layout.span / 2 + node.leftOverhang) : layout.span / 2 + node.rightOverhang, + localZ: layout.roofCenterZ, + y: layout.roofCenterY + node.flashingHeight / 3, + rotationX: layout.pitchRadians, + role: 'roof', + material: flashingMaterial, + slotId: 'flashing', + }) + } + } + + const leftBeamExtension = cornerJoints.left?.beamExtension ?? 0 + const rightBeamExtension = cornerJoints.right?.beamExtension ?? 0 + const leftMiterCenter = cornerJoints.left ? -layout.span / 2 - leftBeamExtension : null + const rightMiterCenter = cornerJoints.right ? layout.span / 2 + rightBeamExtension : null + const beamMinX = + leftMiterCenter === null ? -layout.beamSpan / 2 : leftMiterCenter - node.beamWidth / 2 + const beamMaxX = + rightMiterCenter === null ? layout.beamSpan / 2 : rightMiterCenter + node.beamWidth / 2 + if (curved) { + addBentStrip({ + name: 'lean-to-front-beam', + centerX: (beamMinX + beamMaxX) / 2, + totalWidth: beamMaxX - beamMinX, + height: node.beamHeight, + depth: node.beamWidth, + localZ: layout.beamZ, + y: layout.beamCenterY, + role: 'joinery', + material: beamMaterial, + slotId: 'beam', + }) + } else { + addMiteredBeam(group, { + minX: beamMinX, + maxX: beamMaxX, + leftMiterCenter, + rightMiterCenter, + leftMiterSlope: Math.tan(cornerJoints.left?.gutterMitre ?? 0), + rightMiterSlope: Math.tan(cornerJoints.right?.gutterMitre ?? 0), + height: node.beamHeight, + depth: node.beamWidth, + y: layout.beamCenterY, + z: layout.beamZ, + colorPreset, + sceneTheme, + material: beamMaterial, + }) + } + + if (!ctx) { + for (const [index, x] of layout.postXs.entries()) { + addBentBox({ + name: `lean-to-post-${index}`, + size: [node.postWidth, layout.postHeight, node.postDepth], + localX: x, + localZ: layout.beamZ, + y: layout.postHeight / 2, + role: 'joinery', + material: postsMaterial, + slotId: 'posts', + }) + if (node.footingStyle !== 'none') { + addBentBox({ + name: `lean-to-post-footing-${index}`, + size: [node.postWidth * footingScale, footingHeight, node.postDepth * footingScale], + localX: x, + localZ: layout.beamZ, + y: footingHeight / 2, + role: 'joinery', + material: footingsMaterial, + slotId: 'footings', + }) + } + } + } + + if (!ctx && node.highSideMode === 'independent-high-beam') { + const highPostHeight = Math.max( + 0.2, + layout.highEdgeHeight - + node.roofThickness / 2 - + node.ledgerHeight + + node.ledgerVerticalOffset, + ) + for (const [index, x] of layout.postXs.entries()) { + addBentBox({ + name: `lean-to-high-post-${index}`, + size: [node.postWidth, highPostHeight, node.postDepth], + localX: x, + localZ: 0, + y: highPostHeight / 2, + role: 'joinery', + material: postsMaterial, + slotId: 'posts', + }) + if (node.footingStyle !== 'none') { + addBentBox({ + name: `lean-to-high-post-footing-${index}`, + size: [node.postWidth * footingScale, footingHeight, node.postDepth * footingScale], + localX: x, + localZ: 0, + y: footingHeight / 2, + role: 'joinery', + material: footingsMaterial, + slotId: 'footings', + }) + } + } + } + + if (node.postBracing === 'knee') { + for (const [index, x] of layout.postXs.entries()) { + if (!isRetainedLowPostX(x)) continue + addBentBox({ + name: `lean-to-knee-brace-${index}`, + size: [node.rafterWidth, node.rafterHeight, Math.min(0.8, layout.projection / 2)], + localX: x, + localZ: Math.max(0, layout.beamZ - 0.22), + y: layout.beamCenterY - 0.22, + rotationX: Math.PI / 4, + role: 'joinery', + material: framingMaterial, + slotId: 'framing', + }) + } + } + + if (node.framingStrategy === 'rafters') { + const roofBuildUp = + node.roofThickness / Math.max(0.1, Math.cos(layout.pitchRadians)) + + (node.shingleThickness ?? 0.025) * Math.cos(layout.pitchRadians) + const rafterY = (z: number) => + layout.highEdgeHeight - + z * Math.tan(layout.pitchRadians) - + roofBuildUp - + node.rafterHeight / 2 + const halfRafterRun = (layout.rafterSlopeLength * Math.cos(layout.pitchRadians)) / 2 + const rafterBackZ = layout.rafterCenterZ - halfRafterRun + const rafterFrontZ = layout.rafterCenterZ + halfRafterRun + for (const [index, x] of layout.rafterXs.entries()) { + if (cornerJoints.left && index === 0) continue + if (cornerJoints.right && index === layout.rafterXs.length - 1) continue + let clippedFrontZ = rafterFrontZ + for (const side of ['left', 'right'] as const) { + const intersection = seamIntersectionAtX(side, x) + if (!intersection) continue + const endRetreat = + (Math.abs(intersection.dzDx) * node.rafterWidth) / 2 + + (Math.sin(layout.pitchRadians) * node.rafterHeight) / 2 + + 0.002 + clippedFrontZ = Math.min(clippedFrontZ, intersection.z - endRetreat) + } + if (clippedFrontZ <= rafterBackZ + 1e-6) continue + if (clippedFrontZ < rafterFrontZ - 1e-6) { + addBoxBetween(group, { + name: `lean-to-rafter-${index}`, + start: [x, rafterY(rafterBackZ), rafterBackZ], + end: [x, rafterY(clippedFrontZ), clippedFrontZ], + width: node.rafterWidth, + height: node.rafterHeight, + role: 'joinery', + colorPreset, + sceneTheme, + material: framingMaterial, + slotId: 'framing', + }) + } else { + addBentBox({ + name: `lean-to-rafter-${index}`, + size: [node.rafterWidth, node.rafterHeight, layout.rafterSlopeLength], + localX: x, + localZ: layout.rafterCenterZ, + y: layout.rafterCenterY, + rotationX: layout.pitchRadians, + role: 'joinery', + material: framingMaterial, + slotId: 'framing', + }) + } + } + for (const [side, joint] of Object.entries(cornerJoints)) { + if (!(joint?.sharedPostOwner && joint.seam)) continue + const [start, end] = joint.seam + const [startX, startZ] = bend(start[0], start[1]) + const [endX, endZ] = bend(end[0], end[1]) + addBoxBetween(group, { + name: `lean-to-${side}-corner-rafter`, + start: [startX, rafterY(start[1]), startZ], + end: [endX, rafterY(end[1]), endZ], + width: node.rafterWidth, + height: node.rafterHeight, + role: 'joinery', + colorPreset, + sceneTheme, + material: framingMaterial, + slotId: 'framing', + }) + } + } else if (node.framingStrategy === 'purlins' || node.framingStrategy === 'covering-specific') { + const coveringSpacing = node.coveringType === 'shingle' ? 0.4 : 0.6 + const spacing = + node.framingStrategy === 'covering-specific' + ? Math.min(node.purlinSpacing, coveringSpacing) + : node.purlinSpacing + const count = Math.max(2, Math.ceil(layout.rafterSlopeLength / spacing) + 1) + for (let index = 0; index < count; index++) { + const fraction = index / (count - 1) + const z = fraction * layout.rafterCenterZ * 2 + const y = layout.rafterCenterY + (layout.rafterCenterZ - z) * Math.tan(layout.pitchRadians) + const retained = retainedWidthAtZ(z) + if (retained.maxX <= retained.minX + 1e-6) continue + addBentStrip({ + name: `lean-to-purlin-${index}`, + centerX: (retained.minX + retained.maxX) / 2, + totalWidth: retained.maxX - retained.minX, + height: node.purlinHeight, + depth: node.purlinWidth, + localZ: z, + y, + rotationX: layout.pitchRadians, + role: 'joinery', + material: framingMaterial, + slotId: 'framing', + }) + } + } + + return group +} diff --git a/packages/nodes/src/lean-to-extension/gutter-corner-integration.test.ts b/packages/nodes/src/lean-to-extension/gutter-corner-integration.test.ts new file mode 100644 index 0000000000..bf9b817afb --- /dev/null +++ b/packages/nodes/src/lean-to-extension/gutter-corner-integration.test.ts @@ -0,0 +1,591 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + clearSceneHistory, + createSceneApi, + type GutterNode, + LeanToExtensionNode, + LevelNode, + type RoofSegmentNode, + useScene, + WallNode, +} from '@pascal-app/core' +import * as THREE from 'three' +import { computeGutterMitres } from '../gutter/corner-mitre' +import { computeSharedEaveY } from '../gutter/eave-align' +import { buildGutterGeometry } from '../gutter/geometry' +import { createLeanToAssembly, leanToRoofSegmentLayoutPatch } from './assembly' +import { resolveLeanToCornerJoints } from './corner-joint' +import { initializeLeanToExtensionSync } from './system' + +type RafFn = (callback: (time: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( + callback, +) => { + callback(0) + return 0 +} +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +type CornerFixture = { + wallA: ReturnType + wallB: ReturnType + leanToA: ReturnType + leanToB: ReturnType +} + +type CornerFixtureOptions = { + reverseA: boolean + reverseB: boolean + angle?: number + autoSpan?: boolean + wallEndGap?: number + profile?: GutterNode['profile'] + size?: number + pitchA?: number + pitchB?: number + highEdgeHeightA?: number + highEdgeHeightB?: number + lowOverhangA?: number + lowOverhangB?: number + leftOverhangA?: number + rightOverhangA?: number + leftOverhangB?: number + rightOverhangB?: number + gutterEnabledA?: boolean + gutterEnabledB?: boolean + flipFaceA?: boolean + flipFaceB?: boolean +} + +function cornerFixture(options: CornerFixtureOptions): CornerFixture { + const { reverseA, reverseB } = options + const wallBX = 4 + (options.wallEndGap ?? 0) + const angle = ((options.angle ?? 90) * Math.PI) / 180 + const wallBCorner: [number, number] = [wallBX, 0] + const wallBAway: [number, number] = [wallBX - 4 * Math.cos(angle), -4 * Math.sin(angle)] + const rotationA = (reverseA ? Math.PI : 0) + (options.flipFaceA ? Math.PI : 0) + const rotationB = (reverseB ? Math.PI : 0) + (options.flipFaceB ? Math.PI : 0) + const wallA = WallNode.parse({ + id: `wall_gutter_a_${Number(reverseA)}_${Number(reverseB)}`, + parentId: 'level_gutter_corner', + start: reverseA ? [4, 0] : [0, 0], + end: reverseA ? [0, 0] : [4, 0], + }) + const wallB = WallNode.parse({ + id: `wall_gutter_b_${Number(reverseA)}_${Number(reverseB)}`, + parentId: 'level_gutter_corner', + start: reverseB ? wallBAway : wallBCorner, + end: reverseB ? wallBCorner : wallBAway, + }) + const leanToA = LeanToExtensionNode.parse({ + id: `leanto_gutter_a_${Number(reverseA)}_${Number(reverseB)}`, + parentId: wallA.id, + autoSpan: options.autoSpan ?? false, + position: [2, 0, Math.cos(rotationA) * 0.05], + rotation: [0, rotationA, 0], + span: 4, + downspoutEnabled: false, + gutterEnabled: options.gutterEnabledA ?? true, + gutterProfile: options.profile ?? 'k-style', + gutterSize: options.size ?? 0.13, + pitch: options.pitchA ?? 10, + highEdgeHeight: options.highEdgeHeightA ?? 2.8, + lowOverhang: options.lowOverhangA ?? 0.25, + leftOverhang: options.leftOverhangA ?? 0, + rightOverhang: options.rightOverhangA ?? 0, + }) + const leanToB = LeanToExtensionNode.parse({ + id: `leanto_gutter_b_${Number(reverseA)}_${Number(reverseB)}`, + parentId: wallB.id, + autoSpan: options.autoSpan ?? false, + position: [2, 0, Math.cos(rotationB) * 0.05], + rotation: [0, rotationB, 0], + span: 4, + downspoutEnabled: false, + gutterEnabled: options.gutterEnabledB ?? true, + gutterProfile: options.profile ?? 'k-style', + gutterSize: options.size ?? 0.13, + pitch: options.pitchB ?? 10, + highEdgeHeight: options.highEdgeHeightB ?? 2.8, + lowOverhang: options.lowOverhangB ?? 0.25, + leftOverhang: options.leftOverhangB ?? 0, + rightOverhang: options.rightOverhangB ?? 0, + }) + return { wallA, wallB, leanToA, leanToB } +} + +function managedGutter( + leanTo: ReturnType, + nodes: Record, +): { gutter: GutterNode; segment: RoofSegmentNode } { + const current = nodes[leanTo.id as AnyNodeId] + if (current?.type !== 'lean-to-extension') throw new Error('missing synchronized lean-to') + const roof = current.children + .map((id) => nodes[id as AnyNodeId]) + .find((node) => node?.type === 'roof') + const segment = + roof?.type === 'roof' + ? roof.children + .map((id) => nodes[id as AnyNodeId]) + .find((node) => node?.type === 'roof-segment') + : undefined + const gutter = + segment?.type === 'roof-segment' + ? segment.children.map((id) => nodes[id as AnyNodeId]).find((node) => node?.type === 'gutter') + : undefined + if (segment?.type !== 'roof-segment' || gutter?.type !== 'gutter') { + throw new Error('missing synchronized managed gutter') + } + return { gutter, segment } +} + +function segmentWorldMatrix( + wall: ReturnType, + leanTo: ReturnType, + segment: RoofSegmentNode, +) { + const wallAngle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + return new THREE.Matrix4() + .makeTranslation(wall.start[0], 0, wall.start[1]) + .multiply(new THREE.Matrix4().makeRotationY(-wallAngle)) + .multiply(new THREE.Matrix4().makeTranslation(...leanTo.position)) + .multiply(new THREE.Matrix4().makeRotationY(leanTo.rotation[1])) + .multiply(new THREE.Matrix4().makeTranslation(...segment.position)) + .multiply(new THREE.Matrix4().makeRotationY(segment.rotation)) +} + +function renderedGutterGeometry( + wall: ReturnType, + leanTo: ReturnType, + subject: { gutter: GutterNode; segment: RoofSegmentNode }, + sibling: { gutter: GutterNode; segment: RoofSegmentNode }, +) { + const siblings = [sibling] + const mitres = computeGutterMitres(subject.gutter, subject.segment, siblings) + const eaveY = computeSharedEaveY(subject.gutter, subject.segment, siblings) + const geometry = buildGutterGeometry( + { ...subject.gutter, hangerStyle: 'none', outlets: [] }, + mitres, + ) + return geometry.applyMatrix4( + segmentWorldMatrix(wall, leanTo, subject.segment) + .multiply( + new THREE.Matrix4().makeTranslation( + subject.gutter.position[0], + eaveY, + subject.gutter.position[2], + ), + ) + .multiply(new THREE.Matrix4().makeRotationY(subject.gutter.rotation)), + ) +} + +function pointKey(point: THREE.Vector3): string { + const scale = 1e5 + return [point.x, point.y, point.z].map((value) => Math.round(value * scale)).join(':') +} + +function openBoundaryPoints(geometry: THREE.BufferGeometry): THREE.Vector3[] { + const position = geometry.getAttribute('position') + const index = geometry.index + const edgeCounts = new Map() + const vertex = (offset: number) => index?.getX(offset) ?? offset + const count = index?.count ?? position.count + for (let offset = 0; offset < count; offset += 3) { + const triangle = [0, 1, 2].map((delta) => + new THREE.Vector3().fromBufferAttribute(position, vertex(offset + delta)), + ) + for (const [from, to] of [ + [0, 1], + [1, 2], + [2, 0], + ] as const) { + const a = triangle[from]! + const b = triangle[to]! + const aKey = pointKey(a) + const bKey = pointKey(b) + const key = aKey < bKey ? `${aKey}|${bKey}` : `${bKey}|${aKey}` + const existing = edgeCounts.get(key) + if (existing) existing.count++ + else edgeCounts.set(key, { count: 1, a, b }) + } + } + const points = new Map() + for (const edge of edgeCounts.values()) { + if (edge.count !== 1) continue + points.set(pointKey(edge.a), edge.a) + points.set(pointKey(edge.b), edge.b) + } + return [...points.values()] +} + +function boundaryHausdorffDistance(left: THREE.Vector3[], right: THREE.Vector3[]): number { + const directed = (source: THREE.Vector3[], target: THREE.Vector3[]) => + Math.max( + ...source.map((point) => Math.min(...target.map((candidate) => point.distanceTo(candidate)))), + ) + return Math.max(directed(left, right), directed(right, left)) +} + +function synchronizeAfterSecondShed(fixture: CornerFixture, creationOrder: 'AB' | 'BA') { + const level = LevelNode.parse({ + id: 'level_gutter_corner', + level: 0, + children: [fixture.wallA.id, fixture.wallB.id], + }) + const firstLeanTo = creationOrder === 'AB' ? fixture.leanToA : fixture.leanToB + const firstWall = creationOrder === 'AB' ? fixture.wallA : fixture.wallB + const secondLeanTo = creationOrder === 'AB' ? fixture.leanToB : fixture.leanToA + const secondWall = creationOrder === 'AB' ? fixture.wallB : fixture.wallA + const baseNodes = Object.fromEntries( + [level, fixture.wallA, fixture.wallB, firstLeanTo].map((node) => [node.id, node]), + ) as Record + const first = createLeanToAssembly(firstLeanTo, undefined, baseNodes) + const initialNodes = Object.fromEntries( + [ + level, + { + ...fixture.wallA, + children: firstWall.id === fixture.wallA.id ? [first.extension.id] : [], + }, + { + ...fixture.wallB, + children: firstWall.id === fixture.wallB.id ? [first.extension.id] : [], + }, + first.extension, + ...first.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes: initialNodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + const sceneApi = createSceneApi(useScene) + const stop = initializeLeanToExtensionSync(sceneApi) + const second = createLeanToAssembly(secondLeanTo, undefined, { + ...useScene.getState().nodes, + [secondLeanTo.id]: secondLeanTo, + }) + sceneApi.createMany?.([ + { node: second.extension, parentId: secondWall.id }, + ...second.children.map((node) => ({ + node, + parentId: (node.parentId as AnyNodeId | null) ?? undefined, + })), + ]) + return stop +} + +describe('persisted lean-to gutter corner', () => { + let stop = () => {} + afterEach(() => stop()) + + const geometryCases: { + name: string + options: Omit + }[] = [ + { + name: 'equal eaves, zero side overhang, k-style 130mm', + options: {}, + }, + { + name: 'editor-default automatic wall span', + options: { autoSpan: true }, + }, + { + name: 'snapped wall endpoints separated within corner tolerance', + options: { wallEndGap: 0.2 }, + }, + { + name: 'unequal pitch/eaves, asymmetric side and low overhangs', + options: { + pitchA: 7, + pitchB: 16, + highEdgeHeightA: 2.8, + highEdgeHeightB: 3.1, + lowOverhangA: 0, + lowOverhangB: 0.4, + leftOverhangA: 0.1, + rightOverhangA: 0.35, + leftOverhangB: 0.25, + rightOverhangB: 0.05, + }, + }, + { + name: 'box profile 80mm', + options: { profile: 'box', size: 0.08, lowOverhangA: 0.15, lowOverhangB: 0.3 }, + }, + { + name: 'half-round profile 250mm', + options: { + profile: 'half-round', + size: 0.25, + leftOverhangA: 0.2, + rightOverhangA: 0.2, + leftOverhangB: 0.2, + rightOverhangB: 0.2, + }, + }, + ] + + test('extends accepted offset wall ends to the same roof corner', () => { + const fixture = cornerFixture({ reverseA: false, reverseB: false, wallEndGap: 0.2 }) + const nodes = Object.fromEntries( + [fixture.wallA, fixture.wallB, fixture.leanToA, fixture.leanToB].map((node) => [ + node.id, + node, + ]), + ) as Record + const jointA = resolveLeanToCornerJoints(fixture.leanToA, fixture.wallA, nodes).right + const jointB = resolveLeanToCornerJoints(fixture.leanToB, fixture.wallB, nodes).left + expect(jointA).toBeDefined() + expect(jointB).toBeDefined() + const segmentA = leanToRoofSegmentLayoutPatch(fixture.leanToA, nodes) + const segmentB = leanToRoofSegmentLayoutPatch(fixture.leanToB, nodes) + const cornerA = new THREE.Vector3(segmentA.width / 2, 0, segmentA.depth / 2).applyMatrix4( + segmentWorldMatrix(fixture.wallA, fixture.leanToA, segmentA as RoofSegmentNode), + ) + const cornerB = new THREE.Vector3(-segmentB.width / 2, 0, segmentB.depth / 2).applyMatrix4( + segmentWorldMatrix(fixture.wallB, fixture.leanToB, segmentB as RoofSegmentNode), + ) + expect(Math.hypot(cornerA.x - cornerB.x, cornerA.z - cornerB.z)).toBeLessThan(1e-6) + }) + + test('persists and renders a complete gutter joint at every angle from 30 through 150 degrees', () => { + const angles = [ + ...Array.from({ length: 121 }, (_, index) => 30 + index), + 30.25, + 44.3, + 89.9, + 90.1, + 113.5, + 149.75, + ] + for (const angle of angles) { + stop() + const fixture = cornerFixture({ reverseA: false, reverseB: false, angle }) + stop = synchronizeAfterSecondShed(fixture, 'AB') + const nodes = useScene.getState().nodes + const a = managedGutter(fixture.leanToA, nodes) + const b = managedGutter(fixture.leanToB, nodes) + const geometryA = renderedGutterGeometry(fixture.wallA, fixture.leanToA, a, b) + const geometryB = renderedGutterGeometry(fixture.wallB, fixture.leanToB, b, a) + const boundaryA = openBoundaryPoints(geometryA) + const boundaryB = openBoundaryPoints(geometryB) + + expect( + (a.gutter.metadata as Record).leanToGutterMitres.right, + ).toBeCloseTo(((180 - angle) * Math.PI) / 360, 8) + expect(boundaryA.length).toBeGreaterThan(3) + expect(boundaryB.length).toBeGreaterThan(3) + expect(boundaryHausdorffDistance(boundaryA, boundaryB)).toBeLessThan(1e-4) + geometryA.dispose() + geometryB.dispose() + } + }, 30000) + + test('recomputes both gutter cuts when a connected wall angle changes', () => { + const fixture = cornerFixture({ reverseA: false, reverseB: false }) + stop = synchronizeAfterSecondShed(fixture, 'AB') + const wallAngle = Math.PI / 3 + + useScene.getState().updateNode(fixture.wallB.id as AnyNodeId, { + end: [4 - 4 * Math.cos(wallAngle), -4 * Math.sin(wallAngle)], + }) + + const nodes = useScene.getState().nodes + const a = managedGutter(fixture.leanToA, nodes) + const b = managedGutter(fixture.leanToB, nodes) + const mitresA = (a.gutter.metadata as Record) + .leanToGutterMitres + const mitresB = (b.gutter.metadata as Record) + .leanToGutterMitres + expect(mitresA.right).toBeCloseTo(Math.PI / 3, 8) + expect(mitresB.left).toBeCloseTo(Math.PI / 3, 8) + }) + + for (const reverseA of [false, true]) { + for (const reverseB of [false, true]) { + for (const creationOrder of ['AB', 'BA'] as const) { + for (const geometryCase of geometryCases) { + test(`joins the full shell: walls ${reverseA ? 'end/start' : 'start/end'} + ${reverseB ? 'start/end' : 'end/start'}, creation ${creationOrder}, ${geometryCase.name}`, () => { + const fixture = cornerFixture({ + reverseA, + reverseB, + ...geometryCase.options, + }) + stop = synchronizeAfterSecondShed(fixture, creationOrder) + const nodes = useScene.getState().nodes + const a = managedGutter(fixture.leanToA, nodes) + const b = managedGutter(fixture.leanToB, nodes) + const geometryA = renderedGutterGeometry(fixture.wallA, fixture.leanToA, a, b) + const geometryB = renderedGutterGeometry(fixture.wallB, fixture.leanToB, b, a) + const boundaryA = openBoundaryPoints(geometryA) + const boundaryB = openBoundaryPoints(geometryB) + expect(boundaryA.length).toBeGreaterThan(3) + expect(boundaryB.length).toBeGreaterThan(3) + expect(boundaryHausdorffDistance(boundaryA, boundaryB)).toBeLessThan(1e-4) + geometryA.dispose() + geometryB.dispose() + }) + } + } + } + } + + for (const [gutterEnabledA, gutterEnabledB] of [ + [true, false], + [false, true], + [false, false], + ] as const) { + test(`keeps unmatched gutter ends capped when enabled=${gutterEnabledA}/${gutterEnabledB}`, () => { + const fixture = cornerFixture({ + reverseA: false, + reverseB: true, + gutterEnabledA, + gutterEnabledB, + }) + stop = synchronizeAfterSecondShed(fixture, 'AB') + const nodes = useScene.getState().nodes + const a = managedGutter(fixture.leanToA, nodes) + const b = managedGutter(fixture.leanToB, nodes) + const geometryA = renderedGutterGeometry(fixture.wallA, fixture.leanToA, a, b) + const geometryB = renderedGutterGeometry(fixture.wallB, fixture.leanToB, b, a) + + expect(a.gutter.visible).toBe(gutterEnabledA) + expect(b.gutter.visible).toBe(gutterEnabledB) + expect(openBoundaryPoints(geometryA)).toEqual([]) + expect(openBoundaryPoints(geometryB)).toEqual([]) + geometryA.dispose() + geometryB.dispose() + }) + } + + test('recaps and rejoins the persisted neighbor when gutter visibility changes', () => { + const fixture = cornerFixture({ reverseA: true, reverseB: false }) + stop = synchronizeAfterSecondShed(fixture, 'AB') + + useScene.getState().updateNode(fixture.leanToB.id as AnyNodeId, { gutterEnabled: false }) + let nodes = useScene.getState().nodes + let a = managedGutter(fixture.leanToA, nodes) + let b = managedGutter(fixture.leanToB, nodes) + let geometryA = renderedGutterGeometry(fixture.wallA, fixture.leanToA, a, b) + expect(openBoundaryPoints(geometryA)).toEqual([]) + geometryA.dispose() + + useScene.getState().updateNode(fixture.leanToB.id as AnyNodeId, { gutterEnabled: true }) + nodes = useScene.getState().nodes + a = managedGutter(fixture.leanToA, nodes) + b = managedGutter(fixture.leanToB, nodes) + geometryA = renderedGutterGeometry(fixture.wallA, fixture.leanToA, a, b) + const geometryB = renderedGutterGeometry(fixture.wallB, fixture.leanToB, b, a) + const boundaryA = openBoundaryPoints(geometryA) + const boundaryB = openBoundaryPoints(geometryB) + expect(boundaryA.length).toBeGreaterThan(3) + expect(boundaryHausdorffDistance(boundaryA, boundaryB)).toBeLessThan(1e-4) + geometryA.dispose() + geometryB.dispose() + }) + + for (const [flipFaceA, flipFaceB] of [ + [true, false], + [false, true], + ] as const) { + test(`rejects non-convex opposite-face layout ${flipFaceA}/${flipFaceB}`, () => { + const fixture = cornerFixture({ reverseA: false, reverseB: false, flipFaceA, flipFaceB }) + stop = synchronizeAfterSecondShed(fixture, 'AB') + const nodes = useScene.getState().nodes + const a = managedGutter(fixture.leanToA, nodes) + const b = managedGutter(fixture.leanToB, nodes) + + expect( + (nodes[fixture.leanToA.id as AnyNodeId]?.metadata as Record) + ?.leanToCornerJoints, + ).toEqual({}) + expect( + (nodes[fixture.leanToB.id as AnyNodeId]?.metadata as Record) + ?.leanToCornerJoints, + ).toEqual({}) + expect((a.gutter.metadata as Record).leanToGutterMitres).toEqual({ + left: 0, + right: 0, + }) + expect((b.gutter.metadata as Record).leanToGutterMitres).toEqual({ + left: 0, + right: 0, + }) + }) + } + + test('persists and renders the concave joint when both sheds face the inner corner', () => { + const fixture = cornerFixture({ + reverseA: false, + reverseB: false, + flipFaceA: true, + flipFaceB: true, + }) + stop = synchronizeAfterSecondShed(fixture, 'AB') + const nodes = useScene.getState().nodes + const a = managedGutter(fixture.leanToA, nodes) + const b = managedGutter(fixture.leanToB, nodes) + const geometryA = renderedGutterGeometry(fixture.wallA, fixture.leanToA, a, b) + const geometryB = renderedGutterGeometry(fixture.wallB, fixture.leanToB, b, a) + const jointA = Object.values( + ((nodes[fixture.leanToA.id as AnyNodeId]?.metadata as Record) + ?.leanToCornerJoints ?? {}) as Record, + )[0] + const jointB = Object.values( + ((nodes[fixture.leanToB.id as AnyNodeId]?.metadata as Record) + ?.leanToCornerJoints ?? {}) as Record, + )[0] + const boundaryA = openBoundaryPoints(geometryA) + const boundaryB = openBoundaryPoints(geometryB) + + expect(jointA?.gutterMitre).toBeCloseTo(-Math.PI / 4, 8) + expect(jointB?.gutterMitre).toBeCloseTo(-Math.PI / 4, 8) + expect(boundaryA.length).toBeGreaterThan(3) + expect(boundaryB.length).toBeGreaterThan(3) + expect(boundaryHausdorffDistance(boundaryA, boundaryB)).toBeLessThan(1e-4) + geometryA.dispose() + geometryB.dispose() + }) + + test('rejects perpendicular walls that cross away from their shed endpoints', () => { + const base = cornerFixture({ reverseA: false, reverseB: false }) + const wallB = WallNode.parse({ + ...base.wallB, + start: [2, 2], + end: [2, -2], + }) + const fixture = { ...base, wallB, leanToB: { ...base.leanToB, parentId: wallB.id } } + stop = synchronizeAfterSecondShed(fixture, 'AB') + const nodes = useScene.getState().nodes + const a = managedGutter(fixture.leanToA, nodes) + const b = managedGutter(fixture.leanToB, nodes) + + expect( + (nodes[fixture.leanToA.id as AnyNodeId]?.metadata as Record) + ?.leanToCornerJoints, + ).toEqual({}) + expect( + (nodes[fixture.leanToB.id as AnyNodeId]?.metadata as Record) + ?.leanToCornerJoints, + ).toEqual({}) + expect((a.gutter.metadata as Record).leanToGutterMitres).toEqual({ + left: 0, + right: 0, + }) + expect((b.gutter.metadata as Record).leanToGutterMitres).toEqual({ + left: 0, + right: 0, + }) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/index.ts b/packages/nodes/src/lean-to-extension/index.ts new file mode 100644 index 0000000000..a808af789f --- /dev/null +++ b/packages/nodes/src/lean-to-extension/index.ts @@ -0,0 +1,10 @@ +export { createLeanToAssembly, createManagedLeanToPost } from './assembly' +export { leanToExtensionDefinition } from './definition' +export { buildLeanToExtensionFloorplan } from './floorplan' +export { buildLeanToExtensionGeometry, leanToExtensionGeometryKey } from './geometry' +export { + leanToWallLocalPose, + resolveLeanToLayout, + resolveLeanToWallPlacement, +} from './layout' +export { LeanToExtensionNode } from './schema' diff --git a/packages/nodes/src/lean-to-extension/layout.test.ts b/packages/nodes/src/lean-to-extension/layout.test.ts new file mode 100644 index 0000000000..9530c2979a --- /dev/null +++ b/packages/nodes/src/lean-to-extension/layout.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from 'bun:test' +import { + AnyNode, + getWallArcData, + getWallCurveFrameAt, + getWallCurveLength, + LeanToExtensionNode, + RoofNode, + WallNode, +} from '@pascal-app/core' +import { + resolveLeanToEdgeSnapTargets, + resolveLeanToLayout, + resolveLeanToMoveCenterX, + resolveLeanToParentPose, + resolveLeanToWallPlacement, + resolveLeanToWallSurfaceHit, +} from './layout' + +describe('lean-to extension layout', () => { + test('derives a descending roof and evenly spaced post row', () => { + const node = LeanToExtensionNode.parse({ + span: 4, + projection: 2.5, + highEdgeHeight: 2.8, + pitch: 10, + postCount: 3, + postInset: 0.2, + }) + const layout = resolveLeanToLayout(node) + expect(layout.lowEdgeHeight).toBeLessThan(layout.highEdgeHeight) + expect(layout.postXs).toEqual([-1.8, 0, 1.8]) + expect(layout.postHeight).toBeGreaterThan(0) + expect(layout.slopeLength).toBeGreaterThan(layout.roofRun) + }) + + test('clamps unsafe pitch to preserve a buildable post height', () => { + const node = LeanToExtensionNode.parse({ + projection: 6, + highEdgeHeight: 1.5, + pitch: 45, + }) + const layout = resolveLeanToLayout(node) + expect(layout.effectivePitchDegrees).toBeLessThan(45) + expect(layout.postHeight).toBeGreaterThanOrEqual(0.2) + }) + + test('derives post count from target spacing', () => { + const node = LeanToExtensionNode.parse({ + span: 8, + postInset: 0, + postLayoutMode: 'target-spacing', + postSpacing: 2, + }) + expect(resolveLeanToLayout(node).postXs).toHaveLength(5) + }) +}) + +describe('lean-to wall placement', () => { + test('creates a separate wall-hosted node without changing a roof node', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], thickness: 0.2, height: 3 }) + const node = resolveLeanToWallPlacement(wall, 3, 'front') + expect(node?.type).toBe('lean-to-extension') + expect(node?.parentId).toBe(wall.id) + expect(node?.position).toEqual([3, 0, 0.1]) + expect(node?.rotation).toEqual([0, 0, 0]) + expect(node?.lowEdgeHeight).toBeCloseTo( + node!.highEdgeHeight - node!.projection * Math.tan((node!.pitch * Math.PI) / 180), + ) + }) + + test('hosts a curved wall with a bent span', () => { + // sagitta 1, half-chord 3 -> R = (3^2 + 1^2) / (2*1) = 5 + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], curveOffset: 1 }) + const node = resolveLeanToWallPlacement(wall, 3, 'front') + expect(node?.type).toBe('lean-to-extension') + expect(node?.parentId).toBe(wall.id) + // The stored arc carries the wall's true radius and a finite signed center. + expect(node?.spanArcRadius).toBeCloseTo(5, 3) + expect(Number.isFinite(node?.spanArcCenterZ ?? Number.NaN)).toBe(true) + expect(Math.abs(node?.spanArcCenterZ ?? 0)).toBeGreaterThan(1e-3) + }) + + test('expresses the curved-wall center in the selected side frame', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], curveOffset: 1, thickness: 0.2 }) + const along = getWallCurveLength(wall) / 2 + const front = resolveLeanToWallPlacement(wall, along, 'front')! + const back = resolveLeanToWallPlacement(wall, along, 'back')! + + expect(front.spanArcRadius).toBeCloseTo(5, 6) + expect(front.spanArcCenterZ).toBeCloseTo(4.9, 6) + expect(back.spanArcRadius).toBeCloseTo(5, 6) + expect(back.spanArcCenterZ).toBeCloseTo(-5.1, 6) + }) + + test('keeps short inner curved roofs outside the arc center', () => { + for (const chord of [2, 3]) { + const wall = WallNode.parse({ + start: [0, 0], + end: [chord, 0], + curveOffset: 0.5, + thickness: 0.2, + }) + const along = getWallCurveLength(wall) / 2 + const inner = resolveLeanToWallPlacement(wall, along, 'front')! + const outer = resolveLeanToWallPlacement(wall, along, 'back')! + const innerLayout = resolveLeanToLayout(inner) + + expect(inner.spanArcCenterZ).toBeGreaterThan(0) + expect(inner.spanArcCenterZ! - innerLayout.roofRun).toBeCloseTo(0.15, 6) + expect(inner.projection).toBeLessThan(2.5) + expect(inner.lowEdgeHeight).toBeCloseTo( + inner.highEdgeHeight - inner.projection * Math.tan((inner.pitch * Math.PI) / 180), + 6, + ) + expect(outer.spanArcCenterZ).toBeLessThan(0) + expect(outer.projection).toBe(2.5) + } + }) + + test('projects tight curved wall face hits onto arc length and side', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [2, 0], + curveOffset: 0.5, + thickness: 0.2, + }) + const wallLength = getWallCurveLength(wall) + + for (const [side, offset] of [ + ['front', 0.1], + ['back', -0.1], + ] as const) { + const t = 0.05 + const frame = getWallCurveFrameAt(wall, t) + const hit = resolveLeanToWallSurfaceHit( + wall, + [frame.point.x + frame.normal.x * offset, 1.5, frame.point.y + frame.normal.y * offset], + [frame.normal.x, 0, frame.normal.y], + ) + + expect(Math.abs(frame.normal.y)).toBeLessThan(0.7) + expect(hit?.localX).toBeCloseTo(wallLength * t, 5) + expect(hit?.side).toBe(side) + } + }) + + test('places a committed curved lean-to at the wall point and tangent', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], curveOffset: 1, thickness: 0.2 }) + const wallLength = getWallCurveLength(wall) + const along = wallLength * 0.25 + const node = resolveLeanToWallPlacement(wall, along, 'front', { span: 1 })! + const frame = getWallCurveFrameAt(wall, node.position[0] / wallLength) + const arc = getWallArcData(wall)! + const pose = resolveLeanToParentPose(wall, node) + + expect(pose.position[0]).toBeCloseTo(frame.point.x + frame.normal.x * 0.1, 5) + expect(pose.position[2]).toBeCloseTo(frame.point.y + frame.normal.y * 0.1, 5) + expect(pose.position[0]).not.toBeCloseTo(node.position[0], 2) + expect(pose.rotationY).toBeCloseTo(-Math.atan2(frame.tangent.y, frame.tangent.x), 6) + expect(Math.hypot(frame.point.x - arc.center.x, frame.point.y - arc.center.y)).toBeCloseTo( + arc.radius, + 6, + ) + }) + + test('moves along the host wall with snapping and roof-edge clamping', () => { + const wall = WallNode.parse({ start: [0, 0], end: [10, 0] }) + const node = LeanToExtensionNode.parse({ + span: 4, + leftOverhang: 0.2, + rightOverhang: 0.4, + }) + + expect(resolveLeanToMoveCenterX(node, wall, 5.26, 0.5)).toBe(5.5) + expect(resolveLeanToMoveCenterX(node, wall, -2)).toBe(2.2) + expect(resolveLeanToMoveCenterX(node, wall, 20)).toBe(7.6) + }) + + test('snaps moving lean-to edges to adjacent lean-to edges on split wall chunks', () => { + const wall = WallNode.parse({ + id: 'wall_left', + parentId: 'level_test', + start: [0, 0], + end: [5, 0], + }) + const adjacentWall = WallNode.parse({ + id: 'wall_right', + parentId: 'level_test', + start: [5, 0], + end: [10, 0], + }) + const moving = LeanToExtensionNode.parse({ + id: 'leanto_left', + parentId: wall.id, + position: [2, 0, 0.05], + span: 2, + leftOverhang: 0, + rightOverhang: 0, + }) + const adjacent = LeanToExtensionNode.parse({ + id: 'leanto_right', + parentId: adjacentWall.id, + position: [1.2, 0, 0.05], + span: 2, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wall.id]: wall, + [adjacentWall.id]: adjacentWall, + [moving.id]: moving, + [adjacent.id]: adjacent, + } as Record + + expect( + resolveLeanToMoveCenterX( + moving, + wall, + 4.1, + 0, + resolveLeanToEdgeSnapTargets(moving, wall, nodes), + ), + ).toBe(4) + }) + + test('keeps existing roof data unchanged when parsed with the extended node union', () => { + const existingRoof = RoofNode.parse({ + children: [], + position: [1, 0, 2], + rotation: 0.35, + segments: [], + }) + const parsed = AnyNode.parse(existingRoof) + expect(parsed).toEqual(existingRoof) + expect(parsed.type).toBe('roof') + }) +}) diff --git a/packages/nodes/src/lean-to-extension/layout.ts b/packages/nodes/src/lean-to-extension/layout.ts new file mode 100644 index 0000000000..6b274d190e --- /dev/null +++ b/packages/nodes/src/lean-to-extension/layout.ts @@ -0,0 +1,393 @@ +import { + type AnyNode, + type AnyNodeId, + getWallArcData, + getWallChordFrame, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + LeanToExtensionNode, + type WallNode, +} from '@pascal-app/core' +import { EAVE_TUCK_INWARD } from '../gutter/eave-snap' +import { type LeanToArcFrame, leanToArcFrameAtLocalX } from './arc' + +export const MIN_LEAN_TO_POST_HEIGHT = 0.2 +export const MIN_LEAN_TO_WALL_LENGTH = 0.6 +export const LEAN_TO_EXTENSION_GEOMETRY_REVISION = 8 +const LEAN_TO_EDGE_SNAP_TOLERANCE = 0.25 +const CURVED_INNER_EDGE_CLEARANCE = 0.15 + +export type LeanToLayout = { + span: number + projection: number + roofRun: number + roofWidth: number + roofCenterX: number + slopeLength: number + rafterSlopeLength: number + pitchRadians: number + effectivePitchDegrees: number + highEdgeHeight: number + lowEdgeHeight: number + eaveEdgeHeight: number + roofCenterY: number + roofCenterZ: number + rafterCenterY: number + rafterCenterZ: number + beamSpan: number + beamCenterY: number + beamZ: number + postHeight: number + postXs: number[] + rafterXs: number[] + postFrames: LeanToArcFrame[] + rafterFrames: LeanToArcFrame[] +} + +export function leanToLowEdgeHeight( + node: Pick, +): number { + return node.highEdgeHeight - node.projection * Math.tan((node.pitch * Math.PI) / 180) +} + +export function resolveLeanToWallSurfaceHit( + wall: WallNode, + localPosition: readonly [number, number, number], + normal: readonly [number, number, number] | undefined, +): { localX: number; side: 'front' | 'back' } | null { + if (!normal) return null + if (!isCurvedWall(wall)) { + if (Math.abs(normal[2]) <= 0.7) return null + return { localX: localPosition[0], side: normal[2] >= 0 ? 'front' : 'back' } + } + if (Math.abs(normal[1]) > 0.7) return null + + const arc = getWallArcData(wall) + if (!arc) return null + const chord = getWallChordFrame(wall) + const point = { + x: chord.start.x + chord.tangent.x * localPosition[0] + chord.normal.x * localPosition[2], + y: chord.start.y + chord.tangent.y * localPosition[0] + chord.normal.y * localPosition[2], + } + const angle = Math.atan2(point.y - arc.center.y, point.x - arc.center.x) + let directedAngle = (angle - arc.startAngle) * arc.direction + while (directedAngle < 0) directedAngle += Math.PI * 2 + const t = Math.max(0, Math.min(1, directedAngle / Math.abs(arc.delta))) + const frame = getWallCurveFrameAt(wall, t) + const signedOffset = + (point.x - frame.point.x) * frame.normal.x + (point.y - frame.point.y) * frame.normal.y + return { + localX: getWallCurveLength(wall) * t, + side: signedOffset >= 0 ? 'front' : 'back', + } +} + +export function applyLeanToCurveProjectionLimit(node: LeanToExtensionNode): LeanToExtensionNode { + const centerZ = node.spanArcCenterZ + if (centerZ == null || centerZ <= 0) return node + const maximumProjection = centerZ - Math.max(0, node.lowOverhang) - CURVED_INNER_EDGE_CLEARANCE + if (maximumProjection < 0.5 || node.projection <= maximumProjection) return node + const projection = maximumProjection + return { + ...node, + projection, + lowEdgeHeight: leanToLowEdgeHeight({ ...node, projection }), + } +} + +export function resolveLeanToLayout(node: LeanToExtensionNode): LeanToLayout { + const span = Math.max(0.5, node.span) + const projection = Math.max(0.5, node.projection) + const highOverhang = Math.max(0, node.highOverhang) + const lowOverhang = Math.max(0, node.lowOverhang) + const roofRun = highOverhang + projection + lowOverhang + const roofWidth = span + Math.max(0, node.leftOverhang) + Math.max(0, node.rightOverhang) + const roofCenterX = (Math.max(0, node.rightOverhang) - Math.max(0, node.leftOverhang)) / 2 + const requestedPitch = (Math.max(1, Math.min(45, node.pitch)) * Math.PI) / 180 + const roofBuildUp = + node.roofThickness / Math.max(0.1, Math.cos(requestedPitch)) + + (node.shingleThickness ?? 0.025) * Math.cos(requestedPitch) + const minimumLowEdge = MIN_LEAN_TO_POST_HEIGHT + node.beamHeight + node.rafterHeight + roofBuildUp + const maximumDrop = Math.max(0, node.highEdgeHeight - minimumLowEdge) + const maximumPitch = Math.atan2(maximumDrop, projection) + const pitchRadians = Math.min(requestedPitch, maximumPitch) + const effectivePitchDegrees = (pitchRadians * 180) / Math.PI + const lowEdgeHeight = node.highEdgeHeight - projection * Math.tan(pitchRadians) + const eaveEdgeHeight = node.highEdgeHeight - (projection + lowOverhang) * Math.tan(pitchRadians) + const roofCenterZ = (projection + lowOverhang - highOverhang) / 2 + const roofCenterY = node.highEdgeHeight - roofCenterZ * Math.tan(pitchRadians) + const effectiveRoofBuildUp = + node.roofThickness / Math.max(0.1, Math.cos(pitchRadians)) + + (node.shingleThickness ?? 0.025) * Math.cos(pitchRadians) + const gutterBackRun = projection + Math.max(0, lowOverhang - EAVE_TUCK_INWARD) + const rafterCornerProjection = (node.rafterHeight / 2) * Math.sin(pitchRadians) + const rafterRun = Math.max( + gutterBackRun - rafterCornerProjection, + projection + node.beamWidth / 2, + ) + const rafterCenterZ = rafterRun / 2 + const rafterCenterY = + node.highEdgeHeight - + rafterCenterZ * Math.tan(pitchRadians) - + effectiveRoofBuildUp - + node.rafterHeight / 2 + const beamZ = Math.max(0, projection - node.lowBeamInset) + const beamTop = + node.highEdgeHeight - beamZ * Math.tan(pitchRadians) - effectiveRoofBuildUp - node.rafterHeight + const beamCenterY = beamTop - node.beamHeight / 2 + const postHeight = Math.max(MIN_LEAN_TO_POST_HEIGHT, beamCenterY - node.beamHeight / 2) + const usablePostSpan = Math.max(0.1, span - 2 * Math.max(0, node.postInset)) + const postCount = + node.postLayoutMode === 'target-spacing' + ? Math.max(2, Math.min(20, Math.ceil(usablePostSpan / node.postSpacing) + 1)) + : node.postCount + const postXs = evenlySpacedXs(span, postCount, node.postInset) + const beamSpan = Math.max( + node.postWidth, + (postXs.at(-1) ?? 0) - (postXs[0] ?? 0) + node.postWidth, + ) + const usableRafterSpan = Math.max(0.1, span - 2 * Math.max(0, node.rafterEndInset)) + const rafterCount = Math.max(2, Math.ceil(usableRafterSpan / node.rafterSpacing) + 1) + const rafterXs = evenlySpacedXs(span, rafterCount, node.rafterEndInset) + + return { + span, + projection, + roofRun, + roofWidth, + roofCenterX, + slopeLength: roofRun / Math.max(0.001, Math.cos(pitchRadians)), + rafterSlopeLength: rafterRun / Math.max(0.001, Math.cos(pitchRadians)), + pitchRadians, + effectivePitchDegrees, + highEdgeHeight: node.highEdgeHeight, + lowEdgeHeight, + eaveEdgeHeight, + roofCenterY, + roofCenterZ, + rafterCenterY, + rafterCenterZ, + beamSpan, + beamCenterY, + beamZ, + postHeight, + postXs, + rafterXs, + postFrames: postXs.map((x) => leanToArcFrameAtLocalX(node, x)), + rafterFrames: rafterXs.map((x) => leanToArcFrameAtLocalX(node, x)), + } +} + +// The host wall's true circular arc expressed in the lean-to's local frame. The +// anchor frame is sampled at the lean-to's along-wall position (the span center), +// so the arc center lies on the local Z axis (local X = 0): `centerZ` is its local +// Z, `radius` is the wall's true radius. Returns null for a straight wall. +export function resolveLeanToSpanArc( + wall: WallNode, + node: Pick, +): { centerZ: number; radius: number } | null { + if (!isCurvedWall(wall)) return null + const arc = getWallArcData(wall) + if (!arc) return null + const arcLength = getWallCurveLength(wall) + if (arcLength <= 1e-6) return null + const t = Math.max(0, Math.min(1, node.position[0] / arcLength)) + const frame = getWallCurveFrameAt(wall, t) + // Signed radial distance from the anchor wall point to the arc center along the + // outward normal (= ±radius; the tangent component is zero by construction). + const d = + (arc.center.x - frame.point.x) * frame.normal.x + + (arc.center.y - frame.point.y) * frame.normal.y + const sideSign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 + return { centerZ: sideSign * (d - node.position[2]), radius: arc.radius } +} + +export function resolveLeanToMoveCenterX( + node: LeanToExtensionNode, + wall: WallNode, + rawLocalX: number, + snapStep = 0, + edgeSnapTargets: readonly LeanToEdgeSnapTarget[] = [], +): number { + const wallLength = getWallCurveLength(wall) + const snapped = snapStep > 0 ? Math.round(rawLocalX / snapStep) * snapStep : rawLocalX + const min = node.span / 2 + Math.max(0, node.leftOverhang) + const max = wallLength - node.span / 2 - Math.max(0, node.rightOverhang) + if (max < min) return wallLength / 2 + const clamped = Math.max(min, Math.min(max, snapped)) + return snapLeanToMoveCenterToEdges(node, clamped, min, max, edgeSnapTargets) +} + +export type LeanToEdgeSnapTarget = { + leftEdgeX: number + rightEdgeX: number +} + +function leanToEdgeSnapTarget(node: LeanToExtensionNode): LeanToEdgeSnapTarget { + return { + leftEdgeX: node.position[0] - node.span / 2 - Math.max(0, node.leftOverhang), + rightEdgeX: node.position[0] + node.span / 2 + Math.max(0, node.rightOverhang), + } +} + +function snapLeanToMoveCenterToEdges( + node: LeanToExtensionNode, + centerX: number, + min: number, + max: number, + targets: readonly LeanToEdgeSnapTarget[], +): number { + const movingLeft = centerX - node.span / 2 - Math.max(0, node.leftOverhang) + const movingRight = centerX + node.span / 2 + Math.max(0, node.rightOverhang) + let best: { centerX: number; distance: number } | null = null + + for (const target of targets) { + const leftToRight = Math.abs(movingLeft - target.rightEdgeX) + if (leftToRight <= LEAN_TO_EDGE_SNAP_TOLERANCE) { + const snappedCenter = target.rightEdgeX + node.span / 2 + Math.max(0, node.leftOverhang) + if (snappedCenter >= min && snappedCenter <= max) { + best = + !best || leftToRight < best.distance + ? { centerX: snappedCenter, distance: leftToRight } + : best + } + } + + const rightToLeft = Math.abs(movingRight - target.leftEdgeX) + if (rightToLeft <= LEAN_TO_EDGE_SNAP_TOLERANCE) { + const snappedCenter = target.leftEdgeX - node.span / 2 - Math.max(0, node.rightOverhang) + if (snappedCenter >= min && snappedCenter <= max) { + best = + !best || rightToLeft < best.distance + ? { centerX: snappedCenter, distance: rightToLeft } + : best + } + } + } + + return best?.centerX ?? centerX +} + +export function resolveLeanToEdgeSnapTargets( + node: LeanToExtensionNode, + wall: WallNode, + nodes: Record, +): LeanToEdgeSnapTarget[] { + const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + if (wallLength <= 1e-6) return [] + const wallDx = (wall.end[0] - wall.start[0]) / wallLength + const wallDz = (wall.end[1] - wall.start[1]) / wallLength + const sameSideSign = Math.sign(Math.cos(node.rotation[1])) || 1 + const targets: LeanToEdgeSnapTarget[] = [] + + for (const candidate of Object.values(nodes)) { + if (candidate.type !== 'lean-to-extension' || candidate.id === node.id) continue + if ((Math.sign(Math.cos(candidate.rotation[1])) || 1) !== sameSideSign) continue + const host = candidate.parentId ? nodes[candidate.parentId as AnyNodeId] : undefined + if (host?.type !== 'wall') continue + const hostLength = Math.hypot(host.end[0] - host.start[0], host.end[1] - host.start[1]) + if (hostLength <= 1e-6) continue + const hostDx = (host.end[0] - host.start[0]) / hostLength + const hostDz = (host.end[1] - host.start[1]) / hostLength + const parallel = wallDx * hostDx + wallDz * hostDz + if (parallel < 0.999) continue + const offsetFromWall = + (host.start[0] - wall.start[0]) * -wallDz + (host.start[1] - wall.start[1]) * wallDx + if (Math.abs(offsetFromWall) > (wall.thickness ?? 0.1) + LEAN_TO_EDGE_SNAP_TOLERANCE) { + continue + } + const hostStartX = + (host.start[0] - wall.start[0]) * wallDx + (host.start[1] - wall.start[1]) * wallDz + const candidateTarget = leanToEdgeSnapTarget(candidate) + targets.push({ + leftEdgeX: hostStartX + candidateTarget.leftEdgeX, + rightEdgeX: hostStartX + candidateTarget.rightEdgeX, + }) + } + + return targets +} + +function evenlySpacedXs(span: number, count: number, requestedInset: number): number[] { + const resolvedCount = Math.max(2, Math.round(count)) + const inset = Math.min(Math.max(0, requestedInset), Math.max(0, span / 2 - 0.05)) + const first = -span / 2 + inset + const last = span / 2 - inset + const step = (last - first) / (resolvedCount - 1) + return Array.from({ length: resolvedCount }, (_, index) => first + index * step) +} + +export function resolveLeanToWallPlacement( + wall: WallNode, + rawLocalX: number, + side: 'front' | 'back', + overrides: Partial = {}, +): LeanToExtensionNode | null { + const wallLength = getWallCurveLength(wall) + if (wallLength < MIN_LEAN_TO_WALL_LENGTH) return null + + const requestedSpan = typeof overrides.span === 'number' ? overrides.span : 4 + const span = Math.max(0.5, Math.min(requestedSpan, wallLength - 0.1)) + const localX = Math.max(span / 2, Math.min(wallLength - span / 2, rawLocalX)) + const thickness = wall.thickness ?? 0.1 + const positionZ = side === 'front' ? thickness / 2 : -thickness / 2 + const rotationY = side === 'front' ? 0 : Math.PI + + const parsed = LeanToExtensionNode.parse({ + ...overrides, + name: overrides.name ?? 'Lean-to Extension', + parentId: wall.id, + position: [localX, 0, positionZ], + rotation: [0, rotationY, 0], + span, + highEdgeHeight: overrides.highEdgeHeight ?? Math.max(1.2, (wall.height ?? 2.4) - 0.1), + }) + const spanArc = resolveLeanToSpanArc(wall, parsed) + return applyLeanToCurveProjectionLimit({ + ...parsed, + spanArcCenterZ: spanArc?.centerZ, + spanArcRadius: spanArc?.radius, + lowEdgeHeight: leanToLowEdgeHeight(parsed), + }) +} + +export function leanToWallLocalPose( + wall: WallNode, + node: LeanToExtensionNode, + baseY: number, +): { position: [number, number, number]; rotationY: number } { + const [localX, localY, localZ] = node.position + const arcLength = getWallCurveLength(wall) + const t = arcLength > 1e-6 ? Math.max(0, Math.min(1, localX / arcLength)) : 0 + const frame = getWallCurveFrameAt(wall, t) + const angle = Math.atan2(frame.tangent.y, frame.tangent.x) + return { + position: [ + frame.point.x + frame.normal.x * localZ, + baseY + localY, + frame.point.y + frame.normal.y * localZ, + ], + rotationY: -angle + node.rotation[1], + } +} + +// The wall mesh is rooted at the chord start and rotated to the chord tangent. +// Curved hosted nodes still store their X coordinate as centerline arc length, +// so their committed renderer must resolve the actual curve point and tangent, +// then express that world pose back in the parent wall mesh's local frame. +export function resolveLeanToParentPose( + wall: WallNode, + node: LeanToExtensionNode, +): { position: [number, number, number]; rotationY: number } { + const worldPose = leanToWallLocalPose(wall, node, 0) + const wallAngle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cos = Math.cos(wallAngle) + const sin = Math.sin(wallAngle) + const dx = worldPose.position[0] - wall.start[0] + const dz = worldPose.position[2] - wall.start[1] + return { + position: [dx * cos + dz * sin, node.position[1], -dx * sin + dz * cos], + rotationY: worldPose.rotationY + wallAngle, + } +} diff --git a/packages/nodes/src/lean-to-extension/move-tool.tsx b/packages/nodes/src/lean-to-extension/move-tool.tsx new file mode 100644 index 0000000000..7bc9495462 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/move-tool.tsx @@ -0,0 +1,94 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + emitter, + type LeanToExtensionNode, + type SceneApi, + useLiveNodeOverrides, + type WallEvent, + type WallNode, +} from '@pascal-app/core' +import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' +import { useEffect } from 'react' +import { resolveLeanToEdgeSnapTargets, resolveLeanToMoveCenterX } from './layout' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' + +type MoveLeanToExtensionProps = { + node: LeanToExtensionNode + sceneApi: SceneApi +} + +const MoveLeanToExtensionTool = ({ node, sceneApi }: MoveLeanToExtensionProps) => { + useEffect(() => { + const parent = node.parentId ? sceneApi.get(node.parentId as AnyNodeId) : undefined + if (parent?.type !== 'wall') return + const wall = parent as WallNode + let lastPatch: Partial | null = null + + const resolvePatch = (event: WallEvent) => { + if (event.node.id !== wall.id) return null + const rawLocalX = event.localPosition[0] + const gridStep = + !event.nativeEvent.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const nodes = sceneApi.nodes() as Record + const position: LeanToExtensionNode['position'] = [ + resolveLeanToMoveCenterX( + node, + wall, + rawLocalX, + gridStep, + event.nativeEvent.altKey ? [] : resolveLeanToEdgeSnapTargets(node, wall, nodes), + ), + node.position[1], + node.position[2], + ] + const candidate = resolveLeanToEndAbutments( + { ...node, position, autoSpan: false }, + wall, + nodes, + ) + const patch: Partial = { + position, + autoSpan: false, + leftEndCondition: candidate.leftEndCondition, + rightEndCondition: candidate.rightEndCondition, + downspoutPosition: candidate.downspoutPosition, + } + useLiveNodeOverrides.getState().set(node.id as AnyNodeId, patch) + sceneApi.markDirty(node.id as AnyNodeId) + lastPatch = leanToPlacementConflicts(candidate, wall, nodes).length === 0 ? patch : null + return lastPatch + } + + const onMove = (event: WallEvent) => { + resolvePatch(event) + } + const onClick = (event: WallEvent) => { + const patch = resolvePatch(event) + if (!patch) return + event.stopPropagation() + useLiveNodeOverrides.getState().clear(node.id as AnyNodeId) + sceneApi.update(node.id as AnyNodeId, patch as Partial) + triggerSFX('sfx:structure-build') + useEditor.getState().setMovingNode(null) + } + + emitter.on('wall:move', onMove) + emitter.on('wall:enter', onMove) + emitter.on('wall:click', onClick) + return () => { + emitter.off('wall:move', onMove) + emitter.off('wall:enter', onMove) + emitter.off('wall:click', onClick) + useLiveNodeOverrides.getState().clear(node.id as AnyNodeId) + sceneApi.markDirty(node.id as AnyNodeId) + lastPatch = null + } + }, [node, sceneApi]) + + return null +} + +export default MoveLeanToExtensionTool diff --git a/packages/nodes/src/lean-to-extension/paint.ts b/packages/nodes/src/lean-to-extension/paint.ts new file mode 100644 index 0000000000..ee129d7446 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/paint.ts @@ -0,0 +1,19 @@ +import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' +import type { LeanToSlotId } from './slots' + +const SLOT_IDS = new Set([ + 'flashing', + 'ledger', + 'beam', + 'framing', + 'posts', + 'footings', +]) + +export const leanToPaint = createSlotPaintCapability({ + resolveRole: ({ hitObject }) => { + const slotId = hitObject?.userData?.slotId + return typeof slotId === 'string' && SLOT_IDS.has(slotId as LeanToSlotId) ? slotId : null + }, + applyPreview: previewGeometrySlot, +}) diff --git a/packages/nodes/src/lean-to-extension/parametrics.test.ts b/packages/nodes/src/lean-to-extension/parametrics.test.ts new file mode 100644 index 0000000000..67c740b36e --- /dev/null +++ b/packages/nodes/src/lean-to-extension/parametrics.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test' +import { LeanToExtensionNode } from '@pascal-app/core' +import { leanToExtensionParametrics } from './parametrics' + +describe('lean-to resize locks', () => { + test('preserves the low edge when projection changes', () => { + const node = LeanToExtensionNode.parse({ resizeLock: 'preserve-low-edge' }) + const low = node.highEdgeHeight - node.projection * Math.tan((node.pitch * Math.PI) / 180) + const patch = { projection: 4 } + const derived = leanToExtensionParametrics.derive?.({ ...node, ...patch }, patch, node) + const high = derived?.highEdgeHeight ?? node.highEdgeHeight + expect(high - patch.projection * Math.tan((node.pitch * Math.PI) / 180)).toBeCloseTo(low) + }) + + test('preserves both edge heights and recalculates pitch in high-edge mode', () => { + const node = LeanToExtensionNode.parse({ resizeLock: 'preserve-high-edge' }) + const originalLow = + node.highEdgeHeight - node.projection * Math.tan((node.pitch * Math.PI) / 180) + const patch = { projection: 4 } + const derived = leanToExtensionParametrics.derive?.({ ...node, ...patch }, patch, node) + + expect(derived?.highEdgeHeight).toBe(node.highEdgeHeight) + expect(derived?.pitch).not.toBe(node.pitch) + expect( + (derived?.highEdgeHeight ?? node.highEdgeHeight) - + patch.projection * Math.tan((((derived?.pitch as number) ?? node.pitch) * Math.PI) / 180), + ).toBeCloseTo(originalLow) + }) + + test('preserves pitch and derives a new low edge in pitch mode', () => { + const node = LeanToExtensionNode.parse({ resizeLock: 'preserve-pitch' }) + const patch = { projection: 4 } + const derived = leanToExtensionParametrics.derive?.({ ...node, ...patch }, patch, node) + + expect(derived?.pitch).toBe(node.pitch) + expect(derived?.lowEdgeHeight).toBeCloseTo( + node.highEdgeHeight - patch.projection * Math.tan((node.pitch * Math.PI) / 180), + ) + }) + + test('accepts an editable low edge while preserving pitch', () => { + const node = LeanToExtensionNode.parse({ resizeLock: 'preserve-pitch' }) + const patch = { lowEdgeHeight: 2 } + const derived = leanToExtensionParametrics.derive?.({ ...node, ...patch }, patch, node) + + expect(derived?.lowEdgeHeight).toBe(2) + expect(derived?.pitch).toBe(node.pitch) + expect(derived?.highEdgeHeight).toBeCloseTo( + 2 + node.projection * Math.tan((node.pitch * Math.PI) / 180), + ) + }) + + test('clears the occupied host edge when switched to manual connection', () => { + const node = LeanToExtensionNode.parse({ + connectionMode: 'auto', + hostRoofId: 'roof_test', + hostRoofSegmentId: 'rseg_test', + hostRoofEdge: '+Z', + hostRoofEdgeRange: [0.25, 0.75], + }) + const patch = { connectionMode: 'manual' as const } + + const derived = leanToExtensionParametrics.derive?.({ ...node, ...patch }, patch, node) + + expect(derived).toMatchObject({ + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + }) + }) + + test('warns when the selected covering pitch is below its advisory minimum', () => { + const node = LeanToExtensionNode.parse({ coveringType: 'shingle', pitch: 5 }) + const issues = leanToExtensionParametrics.invariants?.flatMap((invariant) => invariant(node)) + expect(issues?.some((issue) => issue.severity === 'warning' && issue.field === 'pitch')).toBe( + true, + ) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/parametrics.ts b/packages/nodes/src/lean-to-extension/parametrics.ts new file mode 100644 index 0000000000..b8061445af --- /dev/null +++ b/packages/nodes/src/lean-to-extension/parametrics.ts @@ -0,0 +1,536 @@ +import type { LeanToExtensionNode, ParametricDescriptor } from '@pascal-app/core' +import { leanToLowEdgeHeight, MIN_LEAN_TO_POST_HEIGHT, resolveLeanToLayout } from './layout' + +const degrees = (rise: number, run: number) => + Math.max(1, Math.min(45, (Math.atan2(rise, Math.max(0.001, run)) * 180) / Math.PI)) + +const COVERING_MIN_PITCH: Record = { + generic: null, + shingle: 9.5, + 'metal-panel': 2, +} + +export function deriveLeanToResizePatch( + previous: LeanToExtensionNode, + patch: Partial, +): Partial { + const changesProjection = Object.hasOwn(patch, 'projection') + const changesHigh = Object.hasOwn(patch, 'highEdgeHeight') + const changesLow = Object.hasOwn(patch, 'lowEdgeHeight') + const changesPitch = Object.hasOwn(patch, 'pitch') + if (!(changesProjection || changesHigh || changesLow || changesPitch)) return {} + + const projection = patch.projection ?? previous.projection + let highEdgeHeight = patch.highEdgeHeight ?? previous.highEdgeHeight + let pitch = patch.pitch ?? previous.pitch + let lowEdgeHeight = leanToLowEdgeHeight(previous) + + if (changesLow) { + lowEdgeHeight = patch.lowEdgeHeight ?? lowEdgeHeight + if (previous.resizeLock === 'preserve-pitch') { + highEdgeHeight = lowEdgeHeight + projection * Math.tan((pitch * Math.PI) / 180) + } else { + pitch = degrees(highEdgeHeight - lowEdgeHeight, projection) + lowEdgeHeight = highEdgeHeight - projection * Math.tan((pitch * Math.PI) / 180) + } + } else if (changesProjection && !changesHigh && !changesPitch) { + if (previous.resizeLock === 'preserve-high-edge') { + pitch = degrees(highEdgeHeight - lowEdgeHeight, projection) + } else if (previous.resizeLock === 'preserve-low-edge') { + highEdgeHeight = lowEdgeHeight + projection * Math.tan((pitch * Math.PI) / 180) + } else { + lowEdgeHeight = highEdgeHeight - projection * Math.tan((pitch * Math.PI) / 180) + } + } else if (changesPitch && !changesHigh) { + if (previous.resizeLock === 'preserve-low-edge') { + highEdgeHeight = lowEdgeHeight + projection * Math.tan((pitch * Math.PI) / 180) + } else { + lowEdgeHeight = highEdgeHeight - projection * Math.tan((pitch * Math.PI) / 180) + } + } else if (changesHigh && !changesPitch) { + if (previous.resizeLock === 'preserve-low-edge') { + pitch = degrees(highEdgeHeight - lowEdgeHeight, projection) + } + lowEdgeHeight = highEdgeHeight - projection * Math.tan((pitch * Math.PI) / 180) + } else { + lowEdgeHeight = highEdgeHeight - projection * Math.tan((pitch * Math.PI) / 180) + } + + return { highEdgeHeight, lowEdgeHeight, pitch } +} + +export const leanToExtensionParametrics: ParametricDescriptor = { + derive: (next, patch, previous = next) => { + return { + ...(patch.connectionMode === 'manual' + ? { + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } + : {}), + ...('roofThickness' in patch || 'shingleThickness' in patch + ? { matchHostRoofStructure: false } + : {}), + ...('span' in patch ? { autoSpan: false } : {}), + ...deriveLeanToResizePatch(previous, patch), + } + }, + groups: [ + { + label: 'Size', + fields: [ + { key: 'autoSpan', label: 'Match host width', kind: 'boolean' }, + { + key: 'span', + label: 'Width', + kind: 'number', + unit: 'm', + min: 0.5, + max: 100, + step: 0.1, + }, + { + key: 'projection', + label: 'Projection', + kind: 'number', + unit: 'm', + min: 0.5, + max: 10, + step: 0.1, + }, + { + key: 'highEdgeHeight', + label: 'Wall-side height', + kind: 'number', + unit: 'm', + min: 0.8, + max: 10, + step: 0.05, + visibleIf: (node) => node.connectionMode === 'manual' || !node.hostRoofSegmentId, + }, + { key: 'pitch', label: 'Slope', kind: 'number', unit: '°', min: 1, max: 45, step: 1 }, + ], + }, + { + label: 'Connection', + fields: [ + { + key: 'connectionMode', + label: 'Roof connection', + kind: 'enum', + options: ['auto', 'manual'], + display: 'segmented', + }, + { + key: 'highSideMode', + label: 'Wall side', + kind: 'enum', + options: ['wall-ledger', 'independent-high-beam'], + }, + { + key: 'connectionOffset', + label: 'Connection offset', + kind: 'number', + unit: 'm', + min: -1, + max: 1, + step: 0.01, + visibleIf: (node) => node.connectionMode === 'auto' && Boolean(node.hostRoofSegmentId), + }, + { + key: 'matchHostRoofMaterial', + label: 'Match host roof material', + kind: 'boolean', + visibleIf: (node) => node.connectionMode === 'auto' && Boolean(node.hostRoofId), + }, + { + key: 'matchHostRoofStructure', + label: 'Match host roof structure', + kind: 'boolean', + visibleIf: (node) => node.connectionMode === 'auto' && Boolean(node.hostRoofId), + }, + ], + }, + { + label: 'Structure', + fields: [ + { + key: 'postLayoutMode', + label: 'Post layout', + kind: 'enum', + options: ['count', 'target-spacing'], + }, + { + key: 'postCount', + label: 'Post count', + kind: 'number', + min: 2, + max: 20, + step: 1, + visibleIf: (node) => node.postLayoutMode === 'count', + }, + { + key: 'postSpacing', + label: 'Post spacing', + kind: 'number', + unit: 'm', + min: 0.3, + max: 10, + step: 0.1, + visibleIf: (node) => node.postLayoutMode === 'target-spacing', + }, + { + key: 'postWidth', + label: 'Post width', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.6, + step: 0.01, + }, + { + key: 'postDepth', + label: 'Post depth', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.6, + step: 0.01, + }, + { + key: 'beamHeight', + label: 'Beam height', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.8, + step: 0.01, + }, + { + key: 'beamWidth', + label: 'Beam width', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.6, + step: 0.01, + }, + { + key: 'framingStrategy', + label: 'Framing', + kind: 'enum', + options: ['hidden', 'rafters', 'purlins', 'covering-specific'], + }, + { key: 'autoMiterCorners', label: 'Auto miter corners', kind: 'boolean' }, + ], + }, + { + label: 'Drainage', + fields: [ + { key: 'gutterEnabled', label: 'Gutters', kind: 'boolean' }, + { + key: 'gutterProfile', + label: 'Gutter profile', + kind: 'enum', + options: ['k-style', 'half-round', 'box'], + visibleIf: (node) => node.gutterEnabled, + }, + { + key: 'gutterSize', + label: 'Gutter size', + kind: 'number', + unit: 'm', + min: 0.04, + max: 0.3, + step: 0.01, + visibleIf: (node) => node.gutterEnabled, + }, + { + key: 'downspoutEnabled', + label: 'Downspout', + kind: 'boolean', + visibleIf: (node) => node.gutterEnabled, + }, + { + key: 'downspoutPosition', + label: 'Downspout position', + kind: 'number', + min: -1, + max: 1, + step: 0.05, + visibleIf: (node) => node.gutterEnabled && node.downspoutEnabled, + }, + ], + }, + { + label: 'Advanced', + fields: [ + { + key: 'resizeLock', + label: 'When resizing', + kind: 'enum', + options: ['preserve-high-edge', 'preserve-low-edge', 'preserve-pitch'], + }, + { + key: 'lowEdgeHeight', + label: 'Outer edge height', + kind: 'number', + unit: 'm', + min: 0.2, + max: 10, + step: 0.05, + visibleIf: (node) => node.connectionMode === 'manual' || !node.hostRoofSegmentId, + }, + { + key: 'roofThickness', + label: 'Roof thickness', + kind: 'number', + unit: 'm', + min: 0.02, + max: 0.5, + step: 0.01, + }, + { + key: 'shingleThickness', + label: 'Shingle thickness', + kind: 'number', + unit: 'm', + min: 0, + max: 0.5, + step: 0.005, + }, + { + key: 'coveringType', + label: 'Roof covering', + kind: 'enum', + options: ['generic', 'shingle', 'metal-panel'], + }, + { + key: 'highOverhang', + label: 'Wall-side overhang', + kind: 'number', + unit: 'm', + min: 0, + max: 1.5, + step: 0.05, + }, + { + key: 'lowOverhang', + label: 'Outer overhang', + kind: 'number', + unit: 'm', + min: 0, + max: 1.5, + step: 0.05, + }, + { + key: 'leftOverhang', + label: 'Left overhang', + kind: 'number', + unit: 'm', + min: 0, + max: 1.5, + step: 0.05, + }, + { + key: 'rightOverhang', + label: 'Right overhang', + kind: 'number', + unit: 'm', + min: 0, + max: 1.5, + step: 0.05, + }, + { key: 'sideFlashing', label: 'Side flashing', kind: 'boolean' }, + { + key: 'flashingProjection', + label: 'Flashing projection', + kind: 'number', + unit: 'm', + min: 0.01, + max: 0.5, + step: 0.005, + visibleIf: (node) => node.sideFlashing, + }, + { + key: 'flashingHeight', + label: 'Flashing height', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.5, + step: 0.01, + visibleIf: (node) => node.sideFlashing, + }, + { + key: 'leftEndCondition', + label: 'Left end', + kind: 'enum', + options: ['open', 'wall-abutment', 'joined'], + }, + { + key: 'rightEndCondition', + label: 'Right end', + kind: 'enum', + options: ['open', 'wall-abutment', 'joined'], + }, + { + key: 'ledgerVerticalOffset', + label: 'High beam offset', + kind: 'number', + unit: 'm', + min: -1, + max: 1, + step: 0.01, + visibleIf: (node) => node.highSideMode === 'independent-high-beam', + }, + { + key: 'ledgerDepth', + label: 'High beam depth', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.5, + step: 0.01, + visibleIf: (node) => node.highSideMode === 'independent-high-beam', + }, + { + key: 'ledgerHeight', + label: 'High beam height', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.8, + step: 0.01, + visibleIf: (node) => node.highSideMode === 'independent-high-beam', + }, + { + key: 'lowBeamInset', + label: 'Beam setback', + kind: 'number', + unit: 'm', + min: 0, + max: 2, + step: 0.05, + }, + { + key: 'postInset', + label: 'Post inset', + kind: 'number', + unit: 'm', + min: 0, + max: 3, + step: 0.05, + }, + { + key: 'rafterWidth', + label: 'Rafter width', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.4, + step: 0.01, + visibleIf: (node) => node.framingStrategy === 'rafters', + }, + { + key: 'rafterHeight', + label: 'Rafter height', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.5, + step: 0.01, + }, + { + key: 'rafterSpacing', + label: 'Rafter spacing', + kind: 'number', + unit: 'm', + min: 0.2, + max: 3, + step: 0.05, + visibleIf: (node) => node.framingStrategy === 'rafters', + }, + { + key: 'rafterEndInset', + label: 'Rafter end inset', + kind: 'number', + unit: 'm', + min: 0, + max: 3, + step: 0.05, + visibleIf: (node) => node.framingStrategy === 'rafters', + }, + { + key: 'purlinWidth', + label: 'Purlin width', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.4, + step: 0.01, + visibleIf: (node) => + node.framingStrategy === 'purlins' || node.framingStrategy === 'covering-specific', + }, + { + key: 'purlinHeight', + label: 'Purlin height', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.5, + step: 0.01, + visibleIf: (node) => + node.framingStrategy === 'purlins' || node.framingStrategy === 'covering-specific', + }, + { + key: 'purlinSpacing', + label: 'Purlin spacing', + kind: 'number', + unit: 'm', + min: 0.2, + max: 3, + step: 0.05, + visibleIf: (node) => + node.framingStrategy === 'purlins' || node.framingStrategy === 'covering-specific', + }, + { key: 'postBracing', label: 'Post bracing', kind: 'enum', options: ['none', 'knee'] }, + { + key: 'footingStyle', + label: 'Footings', + kind: 'enum', + options: ['none', 'base-plate', 'concrete-pad'], + }, + ], + }, + ], + invariants: [ + (node) => { + const layout = resolveLeanToLayout(node) + return layout.effectivePitchDegrees + 1e-6 < node.pitch + ? [ + { + field: 'pitch', + msg: `Pitch is too steep for the selected height and projection; leave at least ${MIN_LEAN_TO_POST_HEIGHT}m of post height.`, + severity: 'error' as const, + }, + ] + : [] + }, + (node) => { + const minimum = COVERING_MIN_PITCH[node.coveringType] + return minimum !== null && node.pitch + 1e-6 < minimum + ? [ + { + field: 'pitch', + msg: `${node.coveringType} covering typically needs at least ${minimum}° pitch; verify the selected product and local requirements.`, + severity: 'warning' as const, + }, + ] + : [] + }, + ], +} diff --git a/packages/nodes/src/lean-to-extension/placement-validation.test.ts b/packages/nodes/src/lean-to-extension/placement-validation.test.ts new file mode 100644 index 0000000000..8cb5d700e9 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement-validation.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + BuildingNode, + getWallCurveLength, + LeanToExtensionNode, + LevelNode, + RoofNode, + RoofSegmentNode, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { resolveLeanToWallPlacement } from './layout' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' +import { applyLeanToWallAutoSpan } from './roof-attachment' + +describe('lean-to placement validation', () => { + test('allows a span crossing a host-wall opening', () => { + const window = WindowNode.parse({ position: [2, 1, 0], width: 1.2 }) + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], children: [window.id] }) + const leanTo = LeanToExtensionNode.parse({ parentId: wall.id, position: [2, 0, 0.05] }) + const nodes = { [wall.id]: wall, [window.id]: window } as Record + expect(leanToPlacementConflicts(leanTo, wall, nodes)).toHaveLength(0) + }) + + test('allows adjacent extensions on the same unsplit wall', () => { + const wall = WallNode.parse({ + id: 'wall_shared', + start: [0, 0], + end: [10, 0], + children: ['leanto_left'], + }) + const existing = LeanToExtensionNode.parse({ + id: 'leanto_left', + parentId: wall.id, + position: [1.5, 0, 0.05], + span: 3, + }) + const candidate = LeanToExtensionNode.parse({ + id: 'leanto_right', + parentId: wall.id, + position: [6.5, 0, 0.05], + span: 7, + }) + const nodes = { [wall.id]: wall, [existing.id]: existing } as Record + + expect(leanToPlacementConflicts(candidate, wall, nodes)).toHaveLength(0) + }) + + test('rejects an overlapping extension hosted by an adjacent wall', () => { + const wall = WallNode.parse({ id: 'wall_candidate', start: [0, 0], end: [6, 0] }) + const adjacentWall = WallNode.parse({ id: 'wall_adjacent', start: [0.2, 0.2], end: [6.2, 0.2] }) + const leanTo = LeanToExtensionNode.parse({ parentId: wall.id, position: [3, 0, 0.05] }) + const adjacent = LeanToExtensionNode.parse({ + parentId: adjacentWall.id, + position: [3, 0, 0.05], + }) + const nodes = Object.fromEntries( + [wall, adjacentWall, adjacent].map((node) => [node.id, node]), + ) as Record + expect(leanToPlacementConflicts(leanTo, wall, nodes)).toHaveLength(1) + }) + + test('allows the second extension that completes an internal L corner', () => { + const wallA = WallNode.parse({ + id: 'wall_inner_placement_a', + parentId: 'level_inner_placement', + start: [0, 0], + end: [4, 0], + children: ['leanto_inner_placement_a'], + }) + const wallB = WallNode.parse({ + id: 'wall_inner_placement_b', + parentId: 'level_inner_placement', + start: [4, 0], + end: [4, 4], + }) + const existing = LeanToExtensionNode.parse({ + id: 'leanto_inner_placement_a', + parentId: wallA.id, + position: [2, 0, 0.05], + span: 4, + }) + const candidate = LeanToExtensionNode.parse({ + id: 'leanto_inner_placement_b', + parentId: wallB.id, + position: [2, 0, 0.05], + span: 4, + }) + const nodes = Object.fromEntries( + [wallA, wallB, existing].map((node) => [node.id, node]), + ) as Record + + expect(leanToPlacementConflicts(candidate, wallB, nodes)).toEqual([]) + }) + + test('allows a curved extension to continue onto a tangent straight wall', () => { + const curvedWall = WallNode.parse({ + id: 'wall_curved_continuation', + parentId: 'level_continuation', + start: [0, 0], + end: [6, 0], + curveOffset: 1, + children: ['leanto_curved_continuation'], + }) + const straightWall = WallNode.parse({ + id: 'wall_straight_continuation', + parentId: 'level_continuation', + start: [6, 0], + end: [10.8, 3.6], + }) + const curvedPlacement = resolveLeanToWallPlacement( + curvedWall, + getWallCurveLength(curvedWall) / 2, + 'front', + )! + const existing = { + ...applyLeanToWallAutoSpan(curvedPlacement, curvedWall), + id: 'leanto_curved_continuation', + } + const straightPlacement = resolveLeanToWallPlacement(straightWall, 3, 'front')! + const candidate = applyLeanToWallAutoSpan(straightPlacement, straightWall) + const nodes = { + [curvedWall.id]: curvedWall, + [straightWall.id]: straightWall, + [existing.id]: existing, + } as Record + + expect(leanToPlacementConflicts(candidate, straightWall, nodes)).toEqual([]) + }) + + test('rejects an adjacent building crossing the canopy footprint', () => { + const building = BuildingNode.parse({ id: 'building_host' }) + const level = LevelNode.parse({ id: 'level_host', parentId: building.id }) + const wall = WallNode.parse({ + id: 'wall_host', + parentId: level.id, + start: [0, 0], + end: [6, 0], + }) + const adjacentBuilding = BuildingNode.parse({ id: 'building_adjacent' }) + const adjacentLevel = LevelNode.parse({ id: 'level_adjacent', parentId: adjacentBuilding.id }) + const adjacentWall = WallNode.parse({ + id: 'wall_other_building', + parentId: adjacentLevel.id, + start: [1, 1], + end: [5, 1], + }) + const leanTo = LeanToExtensionNode.parse({ parentId: wall.id, position: [3, 0, 0.05] }) + const nodes = Object.fromEntries( + [building, level, wall, adjacentBuilding, adjacentLevel, adjacentWall].map((node) => [ + node.id, + node, + ]), + ) as Record + + expect(leanToPlacementConflicts(leanTo, wall, nodes)).toContain( + `adjacent building ${adjacentBuilding.id}`, + ) + }) + + test('resolves an adjacent building at an end as a wall abutment', () => { + const building = BuildingNode.parse({ id: 'building_end_host' }) + const level = LevelNode.parse({ id: 'level_end_host', parentId: building.id }) + const wall = WallNode.parse({ + id: 'wall_end_host', + parentId: level.id, + start: [0, 0], + end: [6, 0], + }) + const adjacentBuilding = BuildingNode.parse({ id: 'building_end_adjacent' }) + const adjacentLevel = LevelNode.parse({ + id: 'level_end_adjacent', + parentId: adjacentBuilding.id, + }) + const adjacentWall = WallNode.parse({ + id: 'wall_end_adjacent', + parentId: adjacentLevel.id, + start: [0.85, -0.5], + end: [0.85, 3.5], + }) + const leanTo = LeanToExtensionNode.parse({ + parentId: wall.id, + position: [3, 0, 0.05], + span: 4, + }) + const nodes = Object.fromEntries( + [building, level, wall, adjacentBuilding, adjacentLevel, adjacentWall].map((node) => [ + node.id, + node, + ]), + ) as Record + + const resolved = resolveLeanToEndAbutments(leanTo, wall, nodes) + expect(resolved.leftEndCondition).toBe('wall-abutment') + expect(resolved.downspoutPosition).toBe(1) + expect(leanToPlacementConflicts(resolved, wall, nodes)).not.toContain( + `adjacent building ${adjacentBuilding.id}`, + ) + }) + + test('still rejects an adjacent wall crossing the middle when another wall resolves an end', () => { + const building = BuildingNode.parse({ id: 'building_mixed_host' }) + const level = LevelNode.parse({ id: 'level_mixed_host', parentId: building.id }) + const wall = WallNode.parse({ + id: 'wall_mixed_host', + parentId: level.id, + start: [0, 0], + end: [6, 0], + }) + const adjacentBuilding = BuildingNode.parse({ id: 'building_mixed_adjacent' }) + const adjacentLevel = LevelNode.parse({ + id: 'level_mixed_adjacent', + parentId: adjacentBuilding.id, + }) + const endWall = WallNode.parse({ + id: 'wall_mixed_end', + parentId: adjacentLevel.id, + start: [0.85, -0.5], + end: [0.85, 3.5], + }) + const crossingWall = WallNode.parse({ + id: 'wall_mixed_crossing', + parentId: adjacentLevel.id, + start: [2, 1], + end: [4, 1], + }) + const leanTo = LeanToExtensionNode.parse({ + parentId: wall.id, + position: [3, 0, 0.05], + span: 4, + }) + const nodes = Object.fromEntries( + [building, level, wall, adjacentBuilding, adjacentLevel, endWall, crossingWall].map( + (node) => [node.id, node], + ), + ) as Record + const resolved = resolveLeanToEndAbutments(leanTo, wall, nodes) + + expect(resolved.leftEndCondition).toBe('wall-abutment') + expect(leanToPlacementConflicts(resolved, wall, nodes)).toContain( + `adjacent building ${adjacentBuilding.id}`, + ) + }) + + test('rejects a neighboring roof volume intersecting the canopy', () => { + const building = BuildingNode.parse({ id: 'building_roof' }) + const level = LevelNode.parse({ id: 'level_roof', parentId: building.id }) + const wall = WallNode.parse({ + id: 'wall_roof', + parentId: level.id, + start: [0, 0], + end: [6, 0], + }) + const roof = RoofNode.parse({ + id: 'roof_neighbor', + parentId: level.id, + position: [3, 2.2, 1.5], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_neighbor', + parentId: roof.id, + roofType: 'flat', + width: 4, + depth: 1, + wallHeight: 0.3, + }) + const leanTo = LeanToExtensionNode.parse({ parentId: wall.id, position: [3, 0, 0.05] }) + const nodes = Object.fromEntries( + [building, level, wall, { ...roof, children: [segment.id] }, segment].map((node) => [ + node.id, + node, + ]), + ) as Record + + expect(leanToPlacementConflicts(leanTo, wall, nodes)).toContain(`roof/eave ${segment.id}`) + }) + + test('allows an unrelated upper-level roof over a lower-level canopy footprint', () => { + const building = BuildingNode.parse({ + id: 'building_multilevel', + children: ['level_ground', 'level_upper'], + }) + const ground = LevelNode.parse({ + id: 'level_ground', + parentId: building.id, + level: 0, + height: 2.5, + children: ['wall_ground'], + }) + const upper = LevelNode.parse({ + id: 'level_upper', + parentId: building.id, + level: 1, + height: 2.5, + children: ['roof_upper'], + }) + const wall = WallNode.parse({ + id: 'wall_ground', + parentId: ground.id, + start: [0, 0], + end: [6, 0], + height: 2.8, + }) + const roof = RoofNode.parse({ + id: 'roof_upper', + parentId: upper.id, + position: [3, 2.2, 1.5], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_upper', + parentId: roof.id, + roofType: 'flat', + width: 4, + depth: 1, + wallHeight: 0.3, + }) + const leanTo = LeanToExtensionNode.parse({ parentId: wall.id, position: [3, 0, 0.05] }) + const nodes = Object.fromEntries( + [building, ground, upper, wall, { ...roof, children: [segment.id] }, segment].map((node) => [ + node.id, + node, + ]), + ) as Record + + expect(leanToPlacementConflicts(leanTo, wall, nodes)).not.toContain(`roof/eave ${segment.id}`) + }) + + test('rejects a host eave that intrudes beyond its recorded connection edge', () => { + const building = BuildingNode.parse({ id: 'building_host_eave' }) + const level = LevelNode.parse({ id: 'level_host_eave', parentId: building.id }) + const wall = WallNode.parse({ + id: 'wall_host_eave', + parentId: level.id, + start: [0, 0], + end: [6, 0], + }) + const roof = RoofNode.parse({ id: 'roof_host_eave', parentId: level.id, position: [3, 2, 1] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_host_eave', + parentId: roof.id, + roofType: 'flat', + width: 4, + depth: 1, + }) + const leanTo = LeanToExtensionNode.parse({ + parentId: wall.id, + position: [3, 0, 0.05], + hostRoofId: roof.id, + hostRoofSegmentId: segment.id, + hostRoofEdge: '+Z', + connectionInset: 0.3, + }) + const nodes = Object.fromEntries( + [building, level, wall, { ...roof, children: [segment.id] }, segment].map((node) => [ + node.id, + node, + ]), + ) as Record + + expect(leanToPlacementConflicts(leanTo, wall, nodes)).toContain(`host roof/eave ${segment.id}`) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/placement-validation.ts b/packages/nodes/src/lean-to-extension/placement-validation.ts new file mode 100644 index 0000000000..7ace9bd17b --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement-validation.ts @@ -0,0 +1,430 @@ +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + getActiveRoofHeight, + getLevelElevations, + type LeanToExtensionNode, + type RoofNode, + type RoofSegmentNode, + type WallNode, +} from '@pascal-app/core' +import { resolveLeanToCornerJoints } from './corner-joint' +import { resolveLeanToLayout } from './layout' +import { type LeanToPlanFacet, leanToPlanFootprintFacets } from './plan-footprint' + +const CLEARANCE = 0.05 + +function overlaps(aCenter: number, aWidth: number, bCenter: number, bWidth: number) { + return Math.abs(aCenter - bCenter) < (aWidth + bWidth) / 2 + CLEARANCE +} + +function leanToSpansOverlap(a: LeanToExtensionNode, b: LeanToExtensionNode) { + return Math.abs(a.position[0] - b.position[0]) < (a.span + b.span) / 2 - 1e-6 +} + +function planBounds(leanTo: LeanToExtensionNode, wall: WallNode) { + const points = leanToPlanFootprintFacets(leanTo, wall).flat() + return { + minX: Math.min(...points.map((point) => point[0]!)), + maxX: Math.max(...points.map((point) => point[0]!)), + minZ: Math.min(...points.map((point) => point[1]!)), + maxZ: Math.max(...points.map((point) => point[1]!)), + } +} + +function boundsOverlap(a: ReturnType, b: ReturnType) { + return ( + a.minX < b.maxX - CLEARANCE && + a.maxX > b.minX + CLEARANCE && + a.minZ < b.maxZ - CLEARANCE && + a.maxZ > b.minZ + CLEARANCE + ) +} + +type Bounds = ReturnType +type PlanPoint = readonly [number, number] + +function transformFacet(facet: LeanToPlanFacet, building?: BuildingNode): LeanToPlanFacet { + return [ + transformPoint(facet[0], building), + transformPoint(facet[1], building), + transformPoint(facet[2], building), + transformPoint(facet[3], building), + ] +} + +function convexFacetsOverlap(a: LeanToPlanFacet, b: LeanToPlanFacet): boolean { + for (const polygon of [a, b]) { + for (let index = 0; index < polygon.length; index++) { + const start = polygon[index]! + const end = polygon[(index + 1) % polygon.length]! + const axis: PlanPoint = [-(end[1] - start[1]), end[0] - start[0]] + const axisLength = Math.hypot(axis[0], axis[1]) + if (axisLength <= 1e-9) continue + const unit: PlanPoint = [axis[0] / axisLength, axis[1] / axisLength] + const project = (point: PlanPoint) => point[0] * unit[0] + point[1] * unit[1] + const aProjection = a.map(project) + const bProjection = b.map(project) + const overlap = + Math.min(Math.max(...aProjection), Math.max(...bProjection)) - + Math.max(Math.min(...aProjection), Math.min(...bProjection)) + if (overlap <= CLEARANCE) return false + } + } + return true +} + +function leanToFootprintsOverlap( + a: LeanToExtensionNode, + aWall: WallNode, + b: LeanToExtensionNode, + bWall: WallNode, + nodes: Record, +): boolean { + const aBuilding = ancestorBuilding(aWall, nodes) + const bBuilding = ancestorBuilding(bWall, nodes) + const aFacets = leanToPlanFootprintFacets(a, aWall).map((facet) => + transformFacet(facet, aBuilding), + ) + const bFacets = leanToPlanFootprintFacets(b, bWall).map((facet) => + transformFacet(facet, bBuilding), + ) + return aFacets.some((aFacet) => bFacets.some((bFacet) => convexFacetsOverlap(aFacet, bFacet))) +} + +function ancestorBuilding( + node: AnyNode | undefined, + nodes: Record, +): BuildingNode | undefined { + let current = node + const seen = new Set() + while (current?.parentId && !seen.has(current.id)) { + seen.add(current.id) + const parent = nodes[current.parentId as AnyNodeId] + if (parent?.type === 'building') return parent + current = parent + } + return undefined +} + +function transformBounds(bounds: Bounds, building?: BuildingNode): Bounds { + const rotation = building?.rotation[1] ?? 0 + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + const points = [ + [bounds.minX, bounds.minZ], + [bounds.minX, bounds.maxZ], + [bounds.maxX, bounds.minZ], + [bounds.maxX, bounds.maxZ], + ].map(([x, z]) => [ + (building?.position[0] ?? 0) + x! * cos + z! * sin, + (building?.position[2] ?? 0) - x! * sin + z! * cos, + ]) + return { + minX: Math.min(...points.map((point) => point[0]!)), + maxX: Math.max(...points.map((point) => point[0]!)), + minZ: Math.min(...points.map((point) => point[1]!)), + maxZ: Math.max(...points.map((point) => point[1]!)), + } +} + +function transformPoint(point: PlanPoint, building?: BuildingNode): PlanPoint { + const rotation = building?.rotation[1] ?? 0 + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [ + (building?.position[0] ?? 0) + point[0] * cos + point[1] * sin, + (building?.position[2] ?? 0) - point[0] * sin + point[1] * cos, + ] +} + +function pointSegmentDistance(point: PlanPoint, start: PlanPoint, end: PlanPoint): number { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSq = dx * dx + dz * dz + if (lengthSq <= 1e-12) return Math.hypot(point[0] - start[0], point[1] - start[1]) + const t = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSq), + ) + return Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dz * t)) +} + +function segmentDistance(a: PlanPoint, b: PlanPoint, c: PlanPoint, d: PlanPoint): number { + const orientation = (p: PlanPoint, q: PlanPoint, r: PlanPoint) => + (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) + const abC = orientation(a, b, c) + const abD = orientation(a, b, d) + const cdA = orientation(c, d, a) + const cdB = orientation(c, d, b) + if (abC * abD <= 0 && cdA * cdB <= 0) return 0 + return Math.min( + pointSegmentDistance(a, c, d), + pointSegmentDistance(b, c, d), + pointSegmentDistance(c, a, b), + pointSegmentDistance(d, a, b), + ) +} + +function leanToEndEdges( + leanTo: LeanToExtensionNode, + wall: WallNode, + building?: BuildingNode, +): { left: readonly [PlanPoint, PlanPoint]; right: readonly [PlanPoint, PlanPoint] } { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.max(1e-6, Math.hypot(dx, dz)) + const along: PlanPoint = [dx / length, dz / length] + const normal: PlanPoint = [-along[1], along[0]] + const side = Math.cos(leanTo.rotation[1]) >= 0 ? 1 : -1 + const outward: PlanPoint = [normal[0] * side, normal[1] * side] + const center: PlanPoint = [ + wall.start[0] + along[0] * leanTo.position[0] + normal[0] * leanTo.position[2], + wall.start[1] + along[1] * leanTo.position[0] + normal[1] * leanTo.position[2], + ] + const edge = (alongOffset: number): readonly [PlanPoint, PlanPoint] => [ + transformPoint( + [ + center[0] + along[0] * alongOffset - outward[0] * leanTo.highOverhang, + center[1] + along[1] * alongOffset - outward[1] * leanTo.highOverhang, + ], + building, + ), + transformPoint( + [ + center[0] + along[0] * alongOffset + outward[0] * (leanTo.projection + leanTo.lowOverhang), + center[1] + along[1] * alongOffset + outward[1] * (leanTo.projection + leanTo.lowOverhang), + ], + building, + ), + ] + return { + left: edge(-leanTo.span / 2 - leanTo.leftOverhang), + right: edge(leanTo.span / 2 + leanTo.rightOverhang), + } +} + +function wallEndHits( + edges: ReturnType, + wall: WallNode, + building: BuildingNode, +): { left: boolean; right: boolean } { + const start = transformPoint([wall.start[0], wall.start[1]], building) + const end = transformPoint([wall.end[0], wall.end[1]], building) + const tolerance = Math.max(CLEARANCE, (wall.thickness ?? 0.1) / 2 + CLEARANCE) + return { + left: segmentDistance(edges.left[0], edges.left[1], start, end) <= tolerance, + right: segmentDistance(edges.right[0], edges.right[1], start, end) <= tolerance, + } +} + +function adjacentBuildingEndHits( + leanTo: LeanToExtensionNode, + wall: WallNode, + nodes: Record, +): { left: boolean; right: boolean } { + const hostBuilding = ancestorBuilding(wall, nodes) + const edges = leanToEndEdges(leanTo, wall, hostBuilding) + let left = false + let right = false + for (const node of Object.values(nodes)) { + if (node.type !== 'wall') continue + const building = ancestorBuilding(node, nodes) + if (!(building && hostBuilding && building.id !== hostBuilding.id)) continue + const hits = wallEndHits(edges, node, building) + left ||= hits.left + right ||= hits.right + } + return { left, right } +} + +export function resolveLeanToEndAbutments( + leanTo: LeanToExtensionNode, + wall: WallNode, + nodes: Record, +): LeanToExtensionNode { + const hits = adjacentBuildingEndHits(leanTo, wall, nodes) + if (!hits.left && !hits.right) return leanTo + return { + ...leanTo, + leftEndCondition: hits.left ? 'wall-abutment' : leanTo.leftEndCondition, + rightEndCondition: hits.right ? 'wall-abutment' : leanTo.rightEndCondition, + downspoutPosition: hits.left && hits.right ? 0 : hits.right ? -1 : 1, + } +} + +function wallWorldBounds(wall: WallNode, building?: BuildingNode): Bounds { + const half = Math.max(CLEARANCE, (wall.thickness ?? 0.1) / 2) + return transformBounds( + { + minX: Math.min(wall.start[0], wall.end[0]) - half, + maxX: Math.max(wall.start[0], wall.end[0]) + half, + minZ: Math.min(wall.start[1], wall.end[1]) - half, + maxZ: Math.max(wall.start[1], wall.end[1]) + half, + }, + building, + ) +} + +function roofSegmentWorldBounds( + roof: RoofNode, + segment: RoofSegmentNode, + building?: BuildingNode, +): Bounds { + const points = roofSegmentLevelPoints(roof, segment) + return transformBounds( + { + minX: Math.min(...points.map((point) => point[0]!)), + maxX: Math.max(...points.map((point) => point[0]!)), + minZ: Math.min(...points.map((point) => point[1]!)), + maxZ: Math.max(...points.map((point) => point[1]!)), + }, + building, + ) +} + +function roofSegmentLevelPoints(roof: RoofNode, segment: RoofSegmentNode): [number, number][] { + const halfX = segment.width / 2 + segment.overhang + const halfZ = segment.depth / 2 + segment.overhang + const segmentCos = Math.cos(segment.rotation) + const segmentSin = Math.sin(segment.rotation) + const roofCos = Math.cos(roof.rotation) + const roofSin = Math.sin(roof.rotation) + return [ + [-halfX, -halfZ], + [-halfX, halfZ], + [halfX, -halfZ], + [halfX, halfZ], + ].map(([x, z]) => { + const sx = segment.position[0] + x! * segmentCos + z! * segmentSin + const sz = segment.position[2] - x! * segmentSin + z! * segmentCos + return [ + roof.position[0] + sx * roofCos + sz * roofSin, + roof.position[2] - sx * roofSin + sz * roofCos, + ] + }) +} + +function hostRoofIntrudesBeyondConnection( + leanTo: LeanToExtensionNode, + wall: WallNode, + roof: RoofNode, + segment: RoofSegmentNode, +): boolean { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.max(1e-6, Math.hypot(dx, dz)) + const along: readonly [number, number] = [dx / length, dz / length] + const side = Math.cos(leanTo.rotation[1]) >= 0 ? 1 : -1 + const outward: readonly [number, number] = [-along[1] * side, along[0] * side] + const origin: readonly [number, number] = [ + wall.start[0] + along[0] * leanTo.position[0], + wall.start[1] + along[1] * leanTo.position[0], + ] + const furthestOutward = Math.max( + ...roofSegmentLevelPoints(roof, segment).map( + ([x, z]) => (x - origin[0]) * outward[0] + (z - origin[1]) * outward[1], + ), + ) + return furthestOutward > leanTo.connectionInset + CLEARANCE +} + +export function leanToPlacementConflicts( + leanTo: LeanToExtensionNode, + wall: WallNode, + nodes: Record, +): string[] { + const conflicts: string[] = [] + for (const childId of wall.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (!child || child.id === leanTo.id) continue + if ( + child.type === 'lean-to-extension' && + Math.cos(child.rotation[1]) * Math.cos(leanTo.rotation[1]) > 0 && + leanToSpansOverlap(leanTo, child) + ) { + conflicts.push(`lean-to extension ${child.id}`) + } + } + const candidateBounds = planBounds(leanTo, wall) + const hostBuilding = ancestorBuilding(wall, nodes) + const candidateWorldBounds = transformBounds(candidateBounds, hostBuilding) + const permittedEndHits = adjacentBuildingEndHits(leanTo, wall, nodes) + const endEdges = leanToEndEdges(leanTo, wall, hostBuilding) + for (const node of Object.values(nodes)) { + if (node.type !== 'lean-to-extension' || node.id === leanTo.id || node.parentId === wall.id) + continue + const host = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + const supportedConcaveJoint = + host?.type === 'wall' && + Object.values(resolveLeanToCornerJoints(leanTo, wall, nodes)).some( + (joint) => joint?.kind === 'concave' && joint.neighborId === node.id, + ) + if ( + host?.type === 'wall' && + !supportedConcaveJoint && + boundsOverlap( + candidateWorldBounds, + transformBounds(planBounds(node, host), ancestorBuilding(host, nodes)), + ) && + leanToFootprintsOverlap(leanTo, wall, node, host, nodes) + ) { + conflicts.push(`adjacent extension ${node.id}`) + } + } + + for (const node of Object.values(nodes)) { + if (node.type !== 'wall' || node.id === wall.id) continue + const building = ancestorBuilding(node, nodes) + if (!(building && hostBuilding && building.id !== hostBuilding.id)) continue + if (boundsOverlap(candidateWorldBounds, wallWorldBounds(node, building))) { + const wallHits = wallEndHits(endEdges, node, building) + if ( + (wallHits.left && permittedEndHits.left && leanTo.leftEndCondition === 'wall-abutment') || + (wallHits.right && permittedEndHits.right && leanTo.rightEndCondition === 'wall-abutment') + ) { + continue + } + conflicts.push(`adjacent building ${building.id}`) + break + } + } + + const elevations = getLevelElevations(nodes) + const wallLevelY = wall.parentId ? (elevations.get(wall.parentId)?.baseY ?? 0) : 0 + const buildingY = hostBuilding?.position[1] ?? 0 + const candidateMinY = + buildingY + wallLevelY + leanTo.position[1] + resolveLeanToLayout(leanTo).lowEdgeHeight + const candidateMaxY = + buildingY + wallLevelY + leanTo.position[1] + leanTo.highEdgeHeight + leanTo.roofThickness + for (const roof of Object.values(nodes)) { + if (roof.type !== 'roof') continue + if ((roof.metadata as Record | undefined)?.managedByLeanTo === leanTo.id) + continue + const roofBuilding = ancestorBuilding(roof, nodes) + if (roofBuilding?.id !== hostBuilding?.id) continue + const roofLevelY = roof.parentId ? (elevations.get(roof.parentId)?.baseY ?? 0) : 0 + const isSameHostLevel = roof.parentId === wall.parentId + for (const childId of roof.children) { + const segment = nodes[childId as AnyNodeId] + if (segment?.type !== 'roof-segment') continue + if (segment.id === leanTo.hostRoofSegmentId) { + if (hostRoofIntrudesBeyondConnection(leanTo, wall, roof, segment)) { + conflicts.push(`host roof/eave ${segment.id}`) + } + continue + } + if (!isSameHostLevel) continue + if (!boundsOverlap(candidateWorldBounds, roofSegmentWorldBounds(roof, segment, roofBuilding))) + continue + const roofMinY = buildingY + roofLevelY + roof.position[1] + segment.position[1] + const roofMaxY = + roofMinY + segment.wallHeight + getActiveRoofHeight(segment) + segment.deckThickness + if (candidateMinY < roofMaxY - CLEARANCE && candidateMaxY > roofMinY + CLEARANCE) { + conflicts.push(`roof/eave ${segment.id}`) + } + } + } + return conflicts +} diff --git a/packages/nodes/src/lean-to-extension/plan-footprint.ts b/packages/nodes/src/lean-to-extension/plan-footprint.ts new file mode 100644 index 0000000000..1a07d959ae --- /dev/null +++ b/packages/nodes/src/lean-to-extension/plan-footprint.ts @@ -0,0 +1,40 @@ +import type { LeanToExtensionNode, WallNode } from '@pascal-app/core' +import { bendLocalPoint, isCurvedLeanTo } from './arc' +import { leanToWallLocalPose, resolveLeanToLayout } from './layout' + +export type LeanToPlanPoint = readonly [number, number] +export type LeanToPlanFacet = readonly [ + LeanToPlanPoint, + LeanToPlanPoint, + LeanToPlanPoint, + LeanToPlanPoint, +] + +export function leanToPlanFootprintFacets( + node: LeanToExtensionNode, + wall: WallNode, +): LeanToPlanFacet[] { + const layout = resolveLeanToLayout(node) + const pose = leanToWallLocalPose(wall, node, 0) + const cos = Math.cos(pose.rotationY) + const sin = Math.sin(pose.rotationY) + const toPlan = (localX: number, localZ: number): LeanToPlanPoint => { + const point = bendLocalPoint(node, localX, localZ) + return [ + pose.position[0] + point.x * cos + point.y * sin, + pose.position[2] - point.x * sin + point.y * cos, + ] + } + const left = layout.span / 2 + node.leftOverhang + const right = layout.span / 2 + node.rightOverhang + const high = -node.highOverhang + const low = layout.projection + node.lowOverhang + const count = isCurvedLeanTo(node) ? Math.max(4, Math.min(32, Math.ceil(node.span / 0.4))) : 1 + const facets: LeanToPlanFacet[] = [] + for (let index = 0; index < count; index++) { + const startX = -left + ((right + left) * index) / count + const endX = -left + ((right + left) * (index + 1)) / count + facets.push([toPlan(startX, high), toPlan(endX, high), toPlan(endX, low), toPlan(startX, low)]) + } + return facets +} diff --git a/packages/nodes/src/lean-to-extension/preview.tsx b/packages/nodes/src/lean-to-extension/preview.tsx new file mode 100644 index 0000000000..424669095c --- /dev/null +++ b/packages/nodes/src/lean-to-extension/preview.tsx @@ -0,0 +1,48 @@ +'use client' + +import type { LeanToExtensionNode } from '@pascal-app/core' +import { EDITOR_LAYER } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo } from 'react' +import type { Material } from 'three' +import { buildLeanToExtensionGeometry } from './geometry' + +const LeanToExtensionPreview = ({ node }: { node: LeanToExtensionNode }) => { + const shading = useViewer((state) => state.shading) + const colorPreset = useViewer((state) => state.colorPreset) + const sceneTheme = useViewer((state) => state.sceneTheme) + const built = useMemo( + () => buildLeanToExtensionGeometry(node, undefined, shading, true, colorPreset, sceneTheme), + [node, shading, colorPreset, sceneTheme], + ) + + useEffect(() => { + const ownedMaterials: Material[] = [] + built.traverse((object) => { + object.layers.set(EDITOR_LAYER) + ;(object as unknown as { raycast: () => void }).raycast = () => {} + const mesh = object as { material?: Material | Material[] } + if (!mesh.material) return + const clone = (material: Material) => { + const copy = material.clone() + copy.transparent = true + copy.opacity = 0.5 + copy.depthWrite = false + ownedMaterials.push(copy) + return copy + } + mesh.material = Array.isArray(mesh.material) ? mesh.material.map(clone) : clone(mesh.material) + }) + return () => { + for (const material of ownedMaterials) material.dispose() + built.traverse((object) => { + const mesh = object as { geometry?: { dispose: () => void } } + mesh.geometry?.dispose() + }) + } + }, [built]) + + return +} + +export default LeanToExtensionPreview diff --git a/packages/nodes/src/lean-to-extension/renderer.tsx b/packages/nodes/src/lean-to-extension/renderer.tsx new file mode 100644 index 0000000000..6e5f6a212a --- /dev/null +++ b/packages/nodes/src/lean-to-extension/renderer.tsx @@ -0,0 +1,63 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type LeanToExtensionNode, + useLiveNodeOverrides, + useLiveTransforms, + useRegistry, + useScene, + type WallNode, +} from '@pascal-app/core' +import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer' +import { useLayoutEffect, useRef } from 'react' +import type { Group } from 'three' +import { resolveLeanToParentPose } from './layout' + +const LeanToExtensionRenderer = ({ node }: { node: LeanToExtensionNode }) => { + const ref = useRef(null!) + const handlers = useNodeEvents(node, 'lean-to-extension') + const liveTransform = useLiveTransforms((state) => state.get(node.id as AnyNodeId)) + const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(node.id)) + const parent = useScene((state) => + node.parentId ? state.nodes[node.parentId as AnyNodeId] : undefined, + ) + + useRegistry(node.id, node.type, ref) + useLayoutEffect(() => { + useScene.getState().markDirty(node.id as AnyNodeId) + }, [node.id]) + + const overridePosition = liveOverride?.position as [number, number, number] | undefined + const overrideRotation = liveOverride?.rotation as [number, number, number] | undefined + const effectiveNode: LeanToExtensionNode = { + ...node, + position: liveTransform?.position ?? overridePosition ?? node.position, + rotation: [ + overrideRotation?.[0] ?? node.rotation[0], + liveTransform?.rotation ?? overrideRotation?.[1] ?? node.rotation[1], + overrideRotation?.[2] ?? node.rotation[2], + ], + } + const pose = + parent?.type === 'wall' + ? resolveLeanToParentPose(parent as WallNode, effectiveNode) + : { position: effectiveNode.position, rotationY: effectiveNode.rotation[1] } + + return ( + + {effectiveNode.children.map((childId) => ( + + ))} + + ) +} + +export default LeanToExtensionRenderer diff --git a/packages/nodes/src/lean-to-extension/roof-attachment.test.ts b/packages/nodes/src/lean-to-extension/roof-attachment.test.ts new file mode 100644 index 0000000000..537ac1b3e3 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/roof-attachment.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + getRoofSegmentVisibleTopBounds, + LeanToExtensionNode, + LevelNode, + RoofNode, + RoofSegmentNode, + spatialGridManager, + WallNode, +} from '@pascal-app/core' +import { getRoofTopSurfaceY } from '../shared/roof-surface' +import { leanToRoofSegmentLayoutPatch } from './assembly' +import { + applyLeanToAvailableWallSpan, + applyLeanToRoofAttachment, + applyLeanToWallAutoSpan, + resolveLeanToRoofAttachment, +} from './roof-attachment' + +function sceneWithRoof( + options: { roofType?: 'gable' | 'hip' | 'shed' | 'flat'; wallHeight?: number } = {}, +) { + const level = LevelNode.parse({ id: 'level_test', name: 'Test level' }) + const wall = WallNode.parse({ + id: 'wall_test', + parentId: level.id, + start: [-2, 3], + end: [2, 3], + height: 3, + thickness: 0.1, + }) + const roof = RoofNode.parse({ + id: 'roof_test', + parentId: level.id, + position: [0, 0, 0], + children: ['rseg_test'], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + parentId: roof.id, + roofType: options.roofType ?? 'gable', + width: 6, + depth: 6, + wallHeight: options.wallHeight ?? 3, + pitch: 30, + overhang: 0.3, + }) + const leanTo = LeanToExtensionNode.parse({ + id: 'leanto_test', + parentId: wall.id, + position: [2, 0, wall.thickness / 2], + rotation: [0, 0, 0], + span: 4, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [roof.id]: roof, + [segment.id]: segment, + [leanTo.id]: leanTo, + } as Record + return { leanTo, nodes, roof, segment, wall } +} + +beforeEach(() => spatialGridManager.clear()) + +describe('lean-to roof-edge attachment', () => { + test('intersects the extension top surface with a compatible gable eave', () => { + const { leanTo, nodes, roof, segment, wall } = sceneWithRoof() + const attachment = resolveLeanToRoofAttachment(leanTo, wall, nodes) + + expect(attachment).not.toBeNull() + expect(attachment?.roofId).toBe(roof.id) + expect(attachment?.roofSegmentId).toBe(segment.id) + expect(attachment?.edge).toBe('+Z') + expect(attachment?.highEdgeHeight).toBeGreaterThan(2.5) + expect(attachment?.highEdgeHeight).toBeLessThan(3.2) + const hostEdgeTop = + roof.position[1] + + segment.position[1] + + getRoofTopSurfaceY(0, segment.depth / 2 + segment.overhang, segment) + const extensionTopAtHostEdge = + attachment!.highEdgeHeight - + attachment!.planDistance * Math.tan((leanTo.pitch * Math.PI) / 180) + expect(extensionTopAtHostEdge).toBeCloseTo(hostEdgeTop, 5) + const connected = applyLeanToRoofAttachment(leanTo, attachment!) + expect(connected.roofThickness).toBe(segment.deckThickness) + expect(connected.shingleThickness).toBe(segment.shingleThickness) + }) + + test('spans and centres the visible extension roof across the full host roof edge', () => { + const initial = sceneWithRoof() + const shiftedRoof = { + ...initial.roof, + position: [1, 0, 0] as [number, number, number], + } + const nodes = { + ...initial.nodes, + [shiftedRoof.id]: shiftedRoof, + } as Record + + const attachment = resolveLeanToRoofAttachment(initial.leanTo, initial.wall, nodes) + expect(attachment).not.toBeNull() + + const connected = applyLeanToRoofAttachment(initial.leanTo, attachment!) + expect(connected.position[0]).toBeCloseTo(3, 5) + expect(connected.span + connected.leftOverhang + connected.rightOverhang).toBeCloseTo(6.6, 5) + expect(connected.hostRoofEdgeRange).toEqual([0, 1]) + expect(connected.lowEdgeHeight).toBeCloseTo( + connected.highEdgeHeight - connected.projection * Math.tan((connected.pitch * Math.PI) / 180), + ) + }) + + test('keeps manual span unchanged when auto span is disabled', () => { + const { leanTo, nodes, wall } = sceneWithRoof() + const manualSpan = LeanToExtensionNode.parse({ + ...leanTo, + autoSpan: false, + position: [1.5, 0, leanTo.position[2]], + span: 3, + }) + const attachment = resolveLeanToRoofAttachment(manualSpan, wall, nodes) + expect(attachment).not.toBeNull() + + const connected = applyLeanToRoofAttachment(manualSpan, attachment!) + expect(connected.position[0]).toBe(1.5) + expect(connected.span).toBe(3) + expect(connected.hostRoofEdgeRange).toBeDefined() + expect(connected.hostRoofEdgeRange![1] - connected.hostRoofEdgeRange![0]).toBeCloseTo(0.5) + }) + + test('falls back to spanning the complete wall when no roof edge is available', () => { + const { leanTo, wall } = sceneWithRoof() + const spanning = applyLeanToWallAutoSpan(leanTo, wall) + + expect(spanning.position[0]).toBeCloseTo(2, 5) + expect(spanning.span + spanning.leftOverhang + spanning.rightOverhang).toBeCloseTo(4, 5) + }) + + test('auto-spans only the free part of a wall that already hosts an extension', () => { + const { leanTo, nodes, wall } = sceneWithRoof() + const existing = LeanToExtensionNode.parse({ + id: 'leanto_existing', + position: [1, 0, leanTo.position[2]], + span: 2, + leftOverhang: 0, + rightOverhang: 0, + }) + const draft = LeanToExtensionNode.parse({ + ...leanTo, + id: 'leanto_draft', + position: [3, 0, leanTo.position[2]], + leftOverhang: 0, + rightOverhang: 0, + }) + const fullWallDraft = applyLeanToWallAutoSpan(draft, wall) + const wallWithExisting = WallNode.parse({ ...wall, children: [existing.id] }) + const availableNodes = Object.fromEntries( + Object.entries(nodes).filter(([id]) => id !== leanTo.id), + ) as Record + const available = applyLeanToAvailableWallSpan( + fullWallDraft, + wallWithExisting, + { ...availableNodes, [existing.id]: existing }, + 3, + ) + + expect(available.position[0]).toBeCloseTo(3, 6) + expect(available.span).toBeCloseTo(2, 6) + }) + + test('connects a ground-floor wall to a roof stored on the level above', () => { + const building = BuildingNode.parse({ + id: 'building_test', + children: ['level_ground', 'level_roof'], + }) + const ground = LevelNode.parse({ + id: 'level_ground', + parentId: building.id, + level: 0, + height: 2.5, + children: ['wall_test'], + }) + const roofLevel = LevelNode.parse({ + id: 'level_roof', + parentId: building.id, + level: 1, + height: 2.5, + children: ['roof_test'], + }) + const wall = WallNode.parse({ + id: 'wall_test', + parentId: ground.id, + start: [-2, 3], + end: [2, 3], + height: 2.5, + thickness: 0.1, + }) + const roof = RoofNode.parse({ + id: 'roof_test', + parentId: roofLevel.id, + children: ['rseg_test'], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + parentId: roof.id, + roofType: 'gable', + width: 6, + depth: 6, + wallHeight: 0, + pitch: 30, + overhang: 0.3, + }) + const leanTo = LeanToExtensionNode.parse({ + id: 'leanto_test', + parentId: wall.id, + position: [2, 0, wall.thickness / 2], + rotation: [0, 0, 0], + span: 4, + }) + const nodes = { + [building.id]: building, + [ground.id]: ground, + [roofLevel.id]: roofLevel, + [wall.id]: wall, + [roof.id]: roof, + [segment.id]: segment, + [leanTo.id]: leanTo, + } as Record + + const attachment = resolveLeanToRoofAttachment(leanTo, wall, nodes) + + expect(attachment).not.toBeNull() + expect(attachment?.roofId).toBe(roof.id) + expect(attachment?.highEdgeHeight).toBeGreaterThan(2.3) + expect(attachment?.highEdgeHeight).toBeLessThan(2.6) + }) + + test('tracks a host roof height change through the persisted edge reference', () => { + const initial = sceneWithRoof({ wallHeight: 3 }) + const first = resolveLeanToRoofAttachment(initial.leanTo, initial.wall, initial.nodes) + expect(first).not.toBeNull() + const connected = applyLeanToRoofAttachment(initial.leanTo, first!) + const raisedSegment = { ...initial.segment, wallHeight: 4 } + const raisedNodes = { + ...initial.nodes, + [raisedSegment.id]: raisedSegment, + } as Record + + const next = resolveLeanToRoofAttachment(connected, initial.wall, raisedNodes, { + roofSegmentId: connected.hostRoofSegmentId, + edge: connected.hostRoofEdge, + }) + + expect(next).not.toBeNull() + expect(next!.highEdgeHeight - first!.highEdgeHeight).toBeCloseTo(1, 5) + }) + + test('supports level perimeter edges on hip, shed, and flat roofs', () => { + for (const roofType of ['hip', 'shed', 'flat'] as const) { + const { leanTo, nodes, wall } = sceneWithRoof({ roofType }) + const attachment = resolveLeanToRoofAttachment(leanTo, wall, nodes) + expect(attachment?.edge).toBe('+Z') + } + }) + + test('keeps a connected extension rooted at the wall beneath a flat host fascia', () => { + const { leanTo, nodes, wall } = sceneWithRoof({ + roofType: 'flat', + }) + const attachment = resolveLeanToRoofAttachment(leanTo, wall, nodes) + expect(attachment).not.toBeNull() + + const connected = applyLeanToRoofAttachment(leanTo, attachment!) + const extensionSegment = RoofSegmentNode.parse(leanToRoofSegmentLayoutPatch(connected)) + const bounds = getRoofSegmentVisibleTopBounds(extensionSegment) + const visibleBack = extensionSegment.position[2] + bounds.minZ + const wallTop = + extensionSegment.position[1] + getRoofTopSurfaceY(0, bounds.minZ + 0.02, extensionSegment) + + expect(visibleBack).toBeCloseTo(-0.02, 6) + expect(wallTop).toBeCloseTo(connected.highEdgeHeight, 5) + }) + + test('does not attach to a managed lean-to roof or a distant roof', () => { + const { leanTo, nodes, roof, wall } = sceneWithRoof() + const managedRoof = { + ...roof, + metadata: { managedByLeanTo: 'leanto_other' }, + position: [0, 0, -10] as [number, number, number], + } + const isolated = { + ...nodes, + [roof.id]: managedRoof, + } as Record + + expect(resolveLeanToRoofAttachment(leanTo, wall, isolated)).toBeNull() + }) +}) diff --git a/packages/nodes/src/lean-to-extension/roof-attachment.ts b/packages/nodes/src/lean-to-extension/roof-attachment.ts new file mode 100644 index 0000000000..2d89edda9e --- /dev/null +++ b/packages/nodes/src/lean-to-extension/roof-attachment.ts @@ -0,0 +1,454 @@ +import { + type AnyNode, + type AnyNodeId, + getLevelElevations, + getWallBaseElevationForNodes, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + type LeanToExtensionNode, + type LeanToRoofEdge, + type RoofNode, + type RoofSegmentNode, + type WallNode, +} from '@pascal-app/core' +import { getRoofTopSurfaceY } from '../shared/roof-surface' +import { leanToLowEdgeHeight } from './layout' + +const MAX_EDGE_DISTANCE = 1.25 +const MIN_EDGE_OVERLAP = 0.35 +const MAX_EDGE_SLOPE_DELTA = 0.06 +const MIN_PARALLEL_DOT = Math.cos((8 * Math.PI) / 180) +const EDGE_SAMPLES = [0, 0.25, 0.5, 0.75, 1] as const +const MIN_EXTENSION_SPAN = 0.5 +const MAX_EXTENSION_SPAN = 100 + +export type LeanToRoofAttachment = { + roofId: RoofNode['id'] + roofSegmentId: RoofSegmentNode['id'] + edge: LeanToRoofEdge + edgeRange: readonly [number, number] + highEdgeHeight: number + planDistance: number + overlap: number + edgeSpan: number + wallLocalCenterX: number + deckThickness: number + shingleThickness: number +} + +type ResolveOptions = { + roofSegmentId?: string + edge?: LeanToRoofEdge +} + +type PlanPoint = { x: number; z: number } +type EdgePoint = PlanPoint & { y: number } + +function metadataRecord(metadata: unknown): Record { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +function rotateY(x: number, z: number, rotation: number): PlanPoint { + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return { x: x * cos + z * sin, z: -x * sin + z * cos } +} + +function segmentPointToLevel( + roof: RoofNode, + segment: RoofSegmentNode, + localX: number, + localZ: number, +): EdgePoint { + const inRoof = rotateY(localX, localZ, segment.rotation ?? 0) + const inLevel = rotateY( + segment.position[0] + inRoof.x, + segment.position[2] + inRoof.z, + roof.rotation ?? 0, + ) + return { + x: roof.position[0] + inLevel.x, + y: roof.position[1] + segment.position[1] + getRoofTopSurfaceY(localX, localZ, segment), + z: roof.position[2] + inLevel.z, + } +} + +function edgeEndpoints( + segment: RoofSegmentNode, + edge: LeanToRoofEdge, +): readonly [[number, number], [number, number]] { + const halfWidth = segment.width / 2 + Math.max(0, segment.overhang ?? 0) + const halfDepth = segment.depth / 2 + Math.max(0, segment.overhang ?? 0) + switch (edge) { + case '+X': + return [ + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + ] + case '-X': + return [ + [-halfWidth, -halfDepth], + [-halfWidth, halfDepth], + ] + case '+Z': + return [ + [-halfWidth, halfDepth], + [halfWidth, halfDepth], + ] + case '-Z': + return [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + ] + } +} + +function sampleEdge(roof: RoofNode, segment: RoofSegmentNode, edge: LeanToRoofEdge): EdgePoint[] { + const [start, end] = edgeEndpoints(segment, edge) + return EDGE_SAMPLES.map((t) => + segmentPointToLevel( + roof, + segment, + start[0] + (end[0] - start[0]) * t, + start[1] + (end[1] - start[1]) * t, + ), + ) +} + +function projection(point: PlanPoint, origin: PlanPoint, axis: PlanPoint): number { + return (point.x - origin.x) * axis.x + (point.z - origin.z) * axis.z +} + +function nearestPointOnSegment(point: PlanPoint, start: PlanPoint, end: PlanPoint): PlanPoint { + const dx = end.x - start.x + const dz = end.z - start.z + const lengthSquared = dx * dx + dz * dz + const t = + lengthSquared <= 1e-9 + ? 0 + : Math.max( + 0, + Math.min(1, ((point.x - start.x) * dx + (point.z - start.z) * dz) / lengthSquared), + ) + return { x: start.x + dx * t, z: start.z + dz * t } +} + +function wallFrame(wall: WallNode, leanTo: LeanToExtensionNode) { + const side = Math.cos(leanTo.rotation[1]) >= 0 ? 1 : -1 + + // Curved host: linearise the arc at the lean-to's along-wall position. + // position[0] is arc-length from the wall start, so the local tangent / + // normal at that param give the along / outward axes the matcher needs. + if (isCurvedWall(wall)) { + const arcLength = getWallCurveLength(wall) + if (arcLength <= 1e-6) return null + const t = Math.max(0, Math.min(1, leanTo.position[0] / arcLength)) + const frame = getWallCurveFrameAt(wall, t) + const along = { x: frame.tangent.x, z: frame.tangent.y } + const perpendicular = { x: frame.normal.x, z: frame.normal.y } + return { + along, + outward: { x: perpendicular.x * side, z: perpendicular.z * side }, + center: { + x: frame.point.x + perpendicular.x * leanTo.position[2], + z: frame.point.y + perpendicular.z * leanTo.position[2], + }, + wallStart: { x: wall.start[0], z: wall.start[1] }, + } + } + + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length <= 1e-6) return null + const along = { x: dx / length, z: dz / length } + const perpendicular = { x: -along.z, z: along.x } + const center = { + x: wall.start[0] + along.x * leanTo.position[0] + perpendicular.x * leanTo.position[2], + z: wall.start[1] + along.z * leanTo.position[0] + perpendicular.z * leanTo.position[2], + } + return { + along, + outward: { x: perpendicular.x * side, z: perpendicular.z * side }, + center, + wallStart: { x: wall.start[0], z: wall.start[1] }, + } +} + +function autoSpanPatch( + leanTo: LeanToExtensionNode, + visibleSpan: number, + wallLocalCenterX: number, +): Pick { + const span = Math.max( + MIN_EXTENSION_SPAN, + Math.min(MAX_EXTENSION_SPAN, visibleSpan - leanTo.leftOverhang - leanTo.rightOverhang), + ) + return { + span, + position: [wallLocalCenterX, leanTo.position[1], leanTo.position[2]], + } +} + +function gutterEdgeRange( + edge: LeanToRoofEdge, + edgeStart: number, + edgeEnd: number, + negReach: number, + posReach: number, +): readonly [number, number] { + const overlapFrom = Math.max(-negReach, Math.min(edgeStart, edgeEnd)) + const overlapTo = Math.min(posReach, Math.max(edgeStart, edgeEnd)) + const delta = edgeEnd - edgeStart + if (Math.abs(delta) <= 1e-9) return [0, 1] + const first = (overlapFrom - edgeStart) / delta + const second = (overlapTo - edgeStart) / delta + const from = Math.max(0, Math.min(1, Math.min(first, second))) + const to = Math.max(0, Math.min(1, Math.max(first, second))) + return edge === '-Z' || edge === '+X' ? [1 - to, 1 - from] : [from, to] +} + +export function resolveLeanToRoofAttachment( + leanTo: LeanToExtensionNode, + wall: WallNode, + nodes: Record, + options: ResolveOptions = {}, +): LeanToRoofAttachment | null { + const frame = wallFrame(wall, leanTo) + if (!frame) return null + const wallBase = getWallBaseElevationForNodes(wall, nodes) + const levelElevations = getLevelElevations(nodes) + const wallLevel = wall.parentId ? levelElevations.get(wall.parentId) : undefined + // The lean-to's footprint along the wall is asymmetric when the left/right + // overhangs differ. The lean-to's local +X maps to +along when it faces the + // wall front (cos(rotationY) >= 0) and flips on the back, so the reaches + // swap sides accordingly. `halfSpan` (the larger reach) is kept for the + // symmetric overlap-acceptance threshold and score. + const alongSide = Math.cos(leanTo.rotation[1]) >= 0 ? 1 : -1 + const leftReach = leanTo.span / 2 + Math.max(0, leanTo.leftOverhang) + const rightReach = leanTo.span / 2 + Math.max(0, leanTo.rightOverhang) + const posReach = alongSide >= 0 ? rightReach : leftReach + const negReach = alongSide >= 0 ? leftReach : rightReach + const halfSpan = Math.max(posReach, negReach) + let best: { attachment: LeanToRoofAttachment; score: number } | null = null + + for (const candidate of Object.values(nodes)) { + if (candidate.type !== 'roof') continue + if (metadataRecord(candidate.metadata).managedByLeanTo) continue + const roof = candidate + const roofLevel = roof.parentId ? levelElevations.get(roof.parentId) : undefined + if (roof.parentId !== wall.parentId) { + if (!(wallLevel && roofLevel) || wallLevel.buildingId !== roofLevel.buildingId) continue + } + const roofToWallY = (roofLevel?.baseY ?? 0) - (wallLevel?.baseY ?? 0) + + for (const childId of roof.children) { + const child = nodes[childId as AnyNodeId] + if (child?.type !== 'roof-segment') continue + const segment = child + if (options.roofSegmentId && segment.id !== options.roofSegmentId) continue + + for (const edge of ['+X', '-X', '+Z', '-Z'] as const) { + if (options.edge && edge !== options.edge) continue + const samples = sampleEdge(roof, segment, edge) + const start = samples[0]! + const end = samples.at(-1)! + const edgeDx = end.x - start.x + const edgeDz = end.z - start.z + const edgeLength = Math.hypot(edgeDx, edgeDz) + if (edgeLength <= 1e-6) continue + const parallel = Math.abs( + (edgeDx / edgeLength) * frame.along.x + (edgeDz / edgeLength) * frame.along.z, + ) + if (parallel < MIN_PARALLEL_DOT) continue + + const ys = samples.map((sample) => sample.y) + const minY = Math.min(...ys) + const maxY = Math.max(...ys) + if (maxY - minY > MAX_EDGE_SLOPE_DELTA) continue + + const edgeStart = projection(start, frame.center, frame.along) + const edgeEnd = projection(end, frame.center, frame.along) + const edgeMin = Math.min(edgeStart, edgeEnd) + const edgeMax = Math.max(edgeStart, edgeEnd) + const overlap = Math.min(posReach, edgeMax) - Math.max(-negReach, edgeMin) + if (overlap < Math.min(MIN_EDGE_OVERLAP, halfSpan * 0.5)) continue + + const nearest = nearestPointOnSegment(frame.center, start, end) + const toEdge = { + x: nearest.x - frame.center.x, + z: nearest.z - frame.center.z, + } + const planDistance = Math.hypot(toEdge.x, toEdge.z) + if (planDistance > MAX_EDGE_DISTANCE) continue + if (toEdge.x * frame.outward.x + toEdge.z * frame.outward.z < -0.1) continue + + const edgeTopY = ys.reduce((sum, value) => sum + value, 0) / ys.length + const highEdgeHeight = + edgeTopY - + wallBase + + roofToWallY + + planDistance * Math.tan((leanTo.pitch * Math.PI) / 180) + + (leanTo.connectionOffset ?? 0) + if (highEdgeHeight < 0.8 || highEdgeHeight > 10) continue + + const attachment: LeanToRoofAttachment = { + roofId: roof.id, + roofSegmentId: segment.id, + edge, + edgeRange: gutterEdgeRange(edge, edgeStart, edgeEnd, negReach, posReach), + highEdgeHeight, + planDistance, + overlap, + edgeSpan: Math.abs( + projection(end, frame.wallStart, frame.along) - + projection(start, frame.wallStart, frame.along), + ), + wallLocalCenterX: + (projection(start, frame.wallStart, frame.along) + + projection(end, frame.wallStart, frame.along)) / + 2, + deckThickness: segment.deckThickness, + shingleThickness: segment.shingleThickness ?? 0, + } + const score = planDistance + (1 - parallel) * 2 - Math.min(overlap, halfSpan * 2) * 0.02 + if (!best || score < best.score) best = { attachment, score } + } + } + } + + return best?.attachment ?? null +} + +export function applyLeanToRoofAttachment( + leanTo: LeanToExtensionNode, + attachment: LeanToRoofAttachment, +): LeanToExtensionNode { + const highEdgeHeight = attachment.highEdgeHeight + const lowEdgeHeight = leanToLowEdgeHeight({ ...leanTo, highEdgeHeight }) + return { + ...leanTo, + ...(leanTo.autoSpan + ? autoSpanPatch(leanTo, attachment.edgeSpan, attachment.wallLocalCenterX) + : {}), + connectionMode: 'auto', + hostRoofId: attachment.roofId, + hostRoofSegmentId: attachment.roofSegmentId, + hostRoofEdge: attachment.edge, + hostRoofEdgeRange: leanTo.autoSpan ? [0, 1] : [...attachment.edgeRange], + connectionInset: attachment.planDistance, + highEdgeHeight, + lowEdgeHeight, + ...(leanTo.matchHostRoofStructure !== false + ? { + roofThickness: attachment.deckThickness, + shingleThickness: attachment.shingleThickness, + } + : {}), + } +} + +export function applyLeanToWallAutoSpan( + leanTo: LeanToExtensionNode, + wall: WallNode, +): LeanToExtensionNode { + if (!leanTo.autoSpan) return leanTo + const wallLength = isCurvedWall(wall) + ? getWallCurveLength(wall) + : Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + if (wallLength <= 1e-6) return leanTo + return { + ...leanTo, + ...autoSpanPatch(leanTo, wallLength, wallLength / 2), + } +} + +export function applyLeanToAvailableWallSpan( + leanTo: LeanToExtensionNode, + wall: WallNode, + nodes: Record, + targetWallX: number, +): LeanToExtensionNode { + if (!leanTo.autoSpan) return leanTo + + const domainStart = leanTo.position[0] - leanTo.span / 2 - leanTo.leftOverhang + const domainEnd = leanTo.position[0] + leanTo.span / 2 + leanTo.rightOverhang + const sameSide = Math.sign(Math.cos(leanTo.rotation[1])) || 1 + const wallChildIds = new Set(wall.children ?? []) + const occupied = Object.values(nodes) + .filter( + (candidate): candidate is LeanToExtensionNode => + candidate.type === 'lean-to-extension' && + candidate.id !== leanTo.id && + (candidate.parentId === wall.id || wallChildIds.has(candidate.id)) && + (Math.sign(Math.cos(candidate.rotation[1])) || 1) === sameSide, + ) + .map((candidate) => ({ + start: candidate.position[0] - candidate.span / 2 - candidate.leftOverhang, + end: candidate.position[0] + candidate.span / 2 + candidate.rightOverhang, + })) + .filter((interval) => interval.end > domainStart && interval.start < domainEnd) + .sort((a, b) => a.start - b.start) + + if (occupied.length === 0) return leanTo + + const free: Array<{ start: number; end: number }> = [] + let cursor = domainStart + for (const interval of occupied) { + const start = Math.max(domainStart, interval.start) + const end = Math.min(domainEnd, interval.end) + if (start > cursor) free.push({ start: cursor, end: start }) + cursor = Math.max(cursor, end) + } + if (cursor < domainEnd) free.push({ start: cursor, end: domainEnd }) + + const targetInterval = free.find( + (interval) => targetWallX >= interval.start - 1e-6 && targetWallX <= interval.end + 1e-6, + ) + if (!targetInterval) return leanTo + + const visibleSpan = targetInterval.end - targetInterval.start + if (visibleSpan < MIN_EXTENSION_SPAN + leanTo.leftOverhang + leanTo.rightOverhang) { + return leanTo + } + + return { + ...leanTo, + ...autoSpanPatch(leanTo, visibleSpan, (targetInterval.start + targetInterval.end) / 2), + } +} + +export function detachLeanToFromRoof(leanTo: LeanToExtensionNode): LeanToExtensionNode { + return { + ...leanTo, + connectionMode: 'manual', + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } +} + +export function clearLeanToRoofAttachment(leanTo: LeanToExtensionNode): LeanToExtensionNode { + return { + ...leanTo, + connectionMode: 'auto', + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } +} + +export function resolveLeanToHostRoof( + leanTo: LeanToExtensionNode, + nodes: Record, +): RoofNode | undefined { + const roof = leanTo.hostRoofId ? nodes[leanTo.hostRoofId as AnyNodeId] : undefined + return roof?.type === 'roof' ? roof : undefined +} diff --git a/packages/nodes/src/lean-to-extension/roof-corner.test.ts b/packages/nodes/src/lean-to-extension/roof-corner.test.ts new file mode 100644 index 0000000000..ed6bed86e5 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/roof-corner.test.ts @@ -0,0 +1,1013 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + getRoofSegmentSurfaceY, + getWallCurveLength, + LeanToExtensionNode, + WallNode, +} from '@pascal-app/core' +import { generateRoofSegmentGeometry } from '@pascal-app/viewer' +import * as THREE from 'three' +import { computeGutterMitres, type GutterMitres } from '../gutter/corner-mitre' +import { buildGutterGeometry } from '../gutter/geometry' +import { bendLocalPoint } from './arc' +import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' +import { resolveLeanToCornerJoints } from './corner-joint' +import { leanToWallLocalPose, resolveLeanToWallPlacement } from './layout' +import { applyLeanToWallAutoSpan } from './roof-attachment' + +function cornerFixture(reverseWalls = false, sideOverhang = 0) { + const wallA = WallNode.parse({ + id: 'wall_corner_a', + parentId: 'level_corner', + start: reverseWalls ? [4, 0] : [0, 0], + end: reverseWalls ? [0, 0] : [4, 0], + }) + const wallB = WallNode.parse({ + id: 'wall_corner_b', + parentId: 'level_corner', + start: reverseWalls ? [4, -4] : [4, 0], + end: reverseWalls ? [4, 0] : [4, -4], + }) + const leanToA = LeanToExtensionNode.parse({ + id: 'leanto_corner_a', + parentId: wallA.id, + position: [2, 0, reverseWalls ? -0.05 : 0.05], + rotation: [0, reverseWalls ? Math.PI : 0, 0], + span: 4, + leftOverhang: sideOverhang, + rightOverhang: sideOverhang, + }) + const leanToB = LeanToExtensionNode.parse({ + id: 'leanto_corner_b', + parentId: wallB.id, + position: [2, 0, reverseWalls ? -0.05 : 0.05], + rotation: [0, reverseWalls ? Math.PI : 0, 0], + span: 4, + highEdgeHeight: 3.1, + pitch: 16, + leftOverhang: sideOverhang, + rightOverhang: sideOverhang, + }) + const nodes = Object.fromEntries( + [wallA, wallB, leanToA, leanToB].map((node) => [node.id, node]), + ) as Record + return { wallA, wallB, leanToA, leanToB, nodes } +} + +function angledCornerFixture(interiorAngleDegrees: number) { + const corner: [number, number] = [4, 0] + const radians = (interiorAngleDegrees * Math.PI) / 180 + const wallA = WallNode.parse({ + id: 'wall_angled_corner_a', + parentId: 'level_angled_corner', + start: [0, 0], + end: corner, + }) + const wallB = WallNode.parse({ + id: 'wall_angled_corner_b', + parentId: 'level_angled_corner', + start: corner, + end: [corner[0] - 4 * Math.cos(radians), -4 * Math.sin(radians)], + }) + const leanToA = LeanToExtensionNode.parse({ + id: 'leanto_angled_corner_a', + parentId: wallA.id, + position: [2, 0, 0.05], + span: 4, + }) + const leanToB = LeanToExtensionNode.parse({ + id: 'leanto_angled_corner_b', + parentId: wallB.id, + position: [2, 0, 0.05], + span: 4, + highEdgeHeight: 3.1, + pitch: 16, + }) + const nodes = Object.fromEntries( + [wallA, wallB, leanToA, leanToB].map((node) => [node.id, node]), + ) as Record + return { wallA, wallB, leanToA, leanToB, nodes } +} + +function innerCornerFixture(interiorAngleDegrees = 90) { + const corner: [number, number] = [4, 0] + const radians = (interiorAngleDegrees * Math.PI) / 180 + const wallA = WallNode.parse({ + id: 'wall_inner_corner_a', + parentId: 'level_inner_corner', + start: [0, 0], + end: corner, + }) + const wallB = WallNode.parse({ + id: 'wall_inner_corner_b', + parentId: 'level_inner_corner', + start: corner, + end: [corner[0] + 4 * Math.cos(radians), 4 * Math.sin(radians)], + }) + const leanToA = LeanToExtensionNode.parse({ + id: 'leanto_inner_corner_a', + parentId: wallA.id, + position: [2, 0, 0.05], + span: 4, + }) + const leanToB = LeanToExtensionNode.parse({ + id: 'leanto_inner_corner_b', + parentId: wallB.id, + position: [2, 0, 0.05], + span: 4, + }) + const nodes = Object.fromEntries( + [wallA, wallB, leanToA, leanToB].map((node) => [node.id, node]), + ) as Record + return { wallA, wallB, leanToA, leanToB, nodes } +} + +const continuousSupportedAngles = [ + ...Array.from({ length: 121 }, (_, index) => 30 + index), + 30.25, + 44.3, + 67.75, + 89.9, + 90.1, + 113.5, + 149.75, +] + +function segmentWorldMatrix( + wall: ReturnType, + leanTo: ReturnType, + segment: ReturnType['segment'], +) { + const pose = leanToWallLocalPose(wall, leanTo, 0) + return new THREE.Matrix4() + .makeTranslation(...pose.position) + .multiply(new THREE.Matrix4().makeRotationY(pose.rotationY)) + .multiply(new THREE.Matrix4().makeTranslation(...segment.position)) + .multiply(new THREE.Matrix4().makeRotationY(segment.rotation)) +} + +function cornerPlanPointToWorld( + wall: ReturnType, + leanTo: ReturnType, + point: readonly [number, number], +) { + const pose = leanToWallLocalPose(wall, leanTo, 0) + const bent = bendLocalPoint(leanTo, point[0], point[1]) + return new THREE.Vector3(bent.x, 0, bent.y).applyMatrix4( + new THREE.Matrix4() + .makeTranslation(...pose.position) + .multiply(new THREE.Matrix4().makeRotationY(pose.rotationY)), + ) +} + +function pointSetHausdorffDistance(left: THREE.Vector3[], right: THREE.Vector3[]): number { + const directed = (source: THREE.Vector3[], target: THREE.Vector3[]) => + Math.max( + ...source.map((point) => Math.min(...target.map((candidate) => point.distanceTo(candidate)))), + ) + return Math.max(directed(left, right), directed(right, left)) +} + +function pointInPolygon(point: readonly [number, number], polygon: THREE.Vector3[]): boolean { + let inside = false + for ( + let current = 0, previous = polygon.length - 1; + current < polygon.length; + previous = current++ + ) { + const a = polygon[current]! + const b = polygon[previous]! + if ( + a.z > point[1] !== b.z > point[1] && + point[0] < ((b.x - a.x) * (point[1] - a.z)) / (b.z - a.z) + a.x + ) { + inside = !inside + } + } + return inside +} + +function assertTopGeometryFollowsRoofSlab( + geometry: THREE.BufferGeometry, + segment: ReturnType['segment'], +) { + const position = geometry.getAttribute('position') + const index = geometry.index + if (!index) throw new Error('expected indexed roof geometry') + const { cosTheta } = getSegmentSlopeFrameForTest(segment) + const thickness = + segment.deckThickness / Math.max(0.1, cosTheta) + segment.shingleThickness * cosTheta + for (const group of geometry.groups) { + if (group.materialIndex !== 3) continue + const end = Math.min(index.count, group.start + group.count) + for (let offset = group.start; offset < end; offset++) { + const vertex = index.getX(offset) + const x = position.getX(vertex) + const y = position.getY(vertex) + const z = position.getZ(vertex) + const top = getRoofSegmentSurfaceY(segment, x, z) + thickness + expect(y).toBeCloseTo(top, 4) + } + } +} + +function getSegmentSlopeFrameForTest(segment: ReturnType['segment']) { + const radians = (segment.pitch * Math.PI) / 180 + return { cosTheta: Math.cos(radians) } +} + +function countTopMaterialVerticalTriangles(geometry: THREE.BufferGeometry): number { + const position = geometry.getAttribute('position') + const index = geometry.index + if (!index) return 0 + let count = 0 + for (const group of geometry.groups) { + if (group.materialIndex !== 3) continue + const end = Math.min(index.count, group.start + group.count) + for (let offset = group.start; offset + 2 < end; offset += 3) { + const a = new THREE.Vector3().fromBufferAttribute(position, index.getX(offset)) + const b = new THREE.Vector3().fromBufferAttribute(position, index.getX(offset + 1)) + const c = new THREE.Vector3().fromBufferAttribute(position, index.getX(offset + 2)) + const normal = b.sub(a).cross(c.sub(a)).normalize() + if (normal.y < 0.2) count++ + } + } + return count +} + +function gutterWorldGeometry( + wall: ReturnType, + leanTo: ReturnType, + assembly: ReturnType, + mitres: GutterMitres, +) { + const geometry = buildGutterGeometry( + { ...assembly.gutter, hangerStyle: 'none', outlets: [] }, + mitres, + ) + const transform = segmentWorldMatrix(wall, leanTo, assembly.segment) + .multiply(new THREE.Matrix4().makeTranslation(...assembly.gutter.position)) + .multiply(new THREE.Matrix4().makeRotationY(assembly.gutter.rotation)) + return geometry.applyMatrix4(transform) +} + +function closestMeshDistance(source: THREE.BufferGeometry, target: THREE.BufferGeometry): number { + const sourcePosition = source.getAttribute('position') + const targetPosition = target.getAttribute('position') + const targetIndex = target.index + const targetVertexCount = targetIndex?.count ?? targetPosition.count + const targetVertex = (offset: number) => targetIndex?.getX(offset) ?? offset + const point = new THREE.Vector3() + const closest = new THREE.Vector3() + const triangle = new THREE.Triangle() + let minimum = Number.POSITIVE_INFINITY + for (let sourceIndex = 0; sourceIndex < sourcePosition.count; sourceIndex++) { + point.fromBufferAttribute(sourcePosition, sourceIndex) + for (let offset = 0; offset < targetVertexCount; offset += 3) { + triangle.a.fromBufferAttribute(targetPosition, targetVertex(offset)) + triangle.b.fromBufferAttribute(targetPosition, targetVertex(offset + 1)) + triangle.c.fromBufferAttribute(targetPosition, targetVertex(offset + 2)) + triangle.closestPointToPoint(point, closest) + const distance = point.distanceTo(closest) + if (Number.isFinite(distance)) minimum = Math.min(minimum, distance) + } + } + return minimum +} + +function contactingVertices(source: THREE.BufferGeometry, target: THREE.BufferGeometry) { + const sourcePosition = source.getAttribute('position') + const targetPosition = target.getAttribute('position') + const targetIndex = target.index + const targetVertexCount = targetIndex?.count ?? targetPosition.count + const targetVertex = (offset: number) => targetIndex?.getX(offset) ?? offset + const point = new THREE.Vector3() + const closest = new THREE.Vector3() + const triangle = new THREE.Triangle() + const contacts: number[][] = [] + for (let sourceIndex = 0; sourceIndex < sourcePosition.count; sourceIndex++) { + point.fromBufferAttribute(sourcePosition, sourceIndex) + let minimum = Number.POSITIVE_INFINITY + for (let offset = 0; offset < targetVertexCount; offset += 3) { + triangle.a.fromBufferAttribute(targetPosition, targetVertex(offset)) + triangle.b.fromBufferAttribute(targetPosition, targetVertex(offset + 1)) + triangle.c.fromBufferAttribute(targetPosition, targetVertex(offset + 2)) + triangle.closestPointToPoint(point, closest) + const distance = point.distanceTo(closest) + if (Number.isFinite(distance)) minimum = Math.min(minimum, distance) + } + if (minimum < 1e-4) contacts.push(point.toArray()) + } + return contacts +} + +function boundaryVerticesNear( + geometry: THREE.BufferGeometry, + center: THREE.Vector3, + radius: number, +): THREE.Vector3[] { + const source = geometry.index ? geometry.toNonIndexed() : geometry + const position = source.getAttribute('position') + const precision = 1e5 + const pointKey = (index: number) => + [position.getX(index), position.getY(index), position.getZ(index)] + .map((value) => Math.round(value * precision)) + .join(':') + const points = new Map() + const edges = new Map() + for (let offset = 0; offset < position.count; offset += 3) { + for (const [a, b] of [ + [offset, offset + 1], + [offset + 1, offset + 2], + [offset + 2, offset], + ] as const) { + const aKey = pointKey(a) + const bKey = pointKey(b) + points.set(aKey, new THREE.Vector3().fromBufferAttribute(position, a)) + points.set(bKey, new THREE.Vector3().fromBufferAttribute(position, b)) + const edgeKey = aKey < bKey ? `${aKey}|${bKey}` : `${bKey}|${aKey}` + edges.set(edgeKey, (edges.get(edgeKey) ?? 0) + 1) + } + } + const boundaryKeys = new Set() + for (const [edge, count] of edges) { + if (count !== 1) continue + const [a, b] = edge.split('|') + boundaryKeys.add(a!) + boundaryKeys.add(b!) + } + if (source !== geometry) source.dispose() + return [...boundaryKeys] + .map((key) => points.get(key)!) + .filter((point) => Math.hypot(point.x - center.x, point.z - center.z) < radius) +} + +describe('lean-to corner joint', () => { + test('partitions an inner L into one valley with connected gutters, beam, and post', () => { + const { wallA, wallB, leanToA, leanToB, nodes } = innerCornerFixture() + const jointA = resolveLeanToCornerJoints(leanToA, wallA, nodes).right + const jointB = resolveLeanToCornerJoints(leanToB, wallB, nodes).left + + expect(jointA?.neighborId).toBe(leanToB.id) + expect(jointB?.neighborId).toBe(leanToA.id) + expect(jointA!.roofExtension).toBeLessThan(0) + expect(jointB?.roofExtension).toBeCloseTo(jointA!.roofExtension, 6) + expect(jointA!.beamExtension).toBeLessThan(0) + expect(jointB?.beamExtension).toBeCloseTo(jointA!.beamExtension, 6) + expect(jointA?.gutterMitre).toBeCloseTo(-Math.PI / 4, 8) + expect(jointB?.gutterMitre).toBeCloseTo(-Math.PI / 4, 8) + + const seamA = jointA?.seam?.map((point) => cornerPlanPointToWorld(wallA, leanToA, point)) + const seamB = jointB?.seam?.map((point) => cornerPlanPointToWorld(wallB, leanToB, point)) + expect(seamA).toHaveLength(2) + expect(seamB).toHaveLength(2) + expect(pointSetHausdorffDistance(seamA!, seamB!)).toBeLessThan(1e-5) + + const assemblyA = createLeanToAssembly(leanToA, undefined, nodes) + const assemblyB = createLeanToAssembly(leanToB, undefined, nodes) + expect(assemblyA.segment.shedFootprintPieces).toHaveLength(1) + expect(assemblyB.segment.shedFootprintPieces).toHaveLength(1) + const roofMeshes = [ + new THREE.Mesh( + generateRoofSegmentGeometry(assemblyA.segment).applyMatrix4( + segmentWorldMatrix(wallA, leanToA, assemblyA.segment), + ), + ), + new THREE.Mesh( + generateRoofSegmentGeometry(assemblyB.segment).applyMatrix4( + segmentWorldMatrix(wallB, leanToB, assemblyB.segment), + ), + ), + ] + const raycaster = new THREE.Raycaster() + raycaster.ray.direction.set(0, -1, 0) + const invalidCoverage: [number, number, number][] = [] + for (let x = 1.4; x < 3.9; x += 0.15) { + for (let z = 0.15; z < 2.7; z += 0.15) { + raycaster.ray.origin.set(x, 10, z) + const owners = roofMeshes.filter( + (mesh) => raycaster.intersectObject(mesh, false).length > 0, + ).length + if (owners !== 1) invalidCoverage.push([x, z, owners]) + } + } + expect(invalidCoverage).toEqual([]) + const gutterA = gutterWorldGeometry( + wallA, + leanToA, + assemblyA, + computeGutterMitres(assemblyA.gutter, assemblyA.segment, [ + { gutter: assemblyB.gutter, segment: assemblyB.segment }, + ]), + ) + const gutterB = gutterWorldGeometry( + wallB, + leanToB, + assemblyB, + computeGutterMitres(assemblyB.gutter, assemblyB.segment, [ + { gutter: assemblyA.gutter, segment: assemblyA.segment }, + ]), + ) + expect(contactingVertices(gutterA, gutterB).length).toBeGreaterThan(10) + expect(contactingVertices(gutterB, gutterA).length).toBeGreaterThan(10) + expect( + [...assemblyA.posts, ...assemblyB.posts].filter((post) => { + const index = managedLeanToPostIndex(post) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }), + ).toHaveLength(1) + expect(assemblyA.posts.some((post) => managedLeanToPostIndex(post) === 2)).toBe(false) + expect(assemblyB.posts.some((post) => managedLeanToPostIndex(post) === 0)).toBe(false) + const regularPostsA = assemblyA.posts.filter( + (post) => (managedLeanToPostIndex(post) ?? -1) >= 0, + ) + const regularPostsB = assemblyB.posts.filter( + (post) => (managedLeanToPostIndex(post) ?? -1) >= 0, + ) + expect( + regularPostsA.every((post) => post.position[0] < jointA!.sharedPostPosition[0] - 1e-6), + ).toBe(true) + expect( + regularPostsB.every((post) => post.position[0] > jointB!.sharedPostPosition[0] + 1e-6), + ).toBe(true) + for (const mesh of roofMeshes) mesh.geometry.dispose() + gutterA.dispose() + gutterB.dispose() + }) + + test('resolves inward V corners continuously across the supported angle range', () => { + for (const angle of continuousSupportedAngles) { + const { wallA, wallB, leanToA, leanToB, nodes } = innerCornerFixture(angle) + const jointA = resolveLeanToCornerJoints(leanToA, wallA, nodes).right + const jointB = resolveLeanToCornerJoints(leanToB, wallB, nodes).left + const expectedMitre = -(angle * Math.PI) / 360 + + expect(jointA?.kind).toBe('concave') + expect(jointB?.kind).toBe('concave') + expect(jointA?.gutterMitre).toBeCloseTo(expectedMitre, 8) + expect(jointB?.gutterMitre).toBeCloseTo(expectedMitre, 8) + expect(jointA!.roofExtension).toBeLessThan(0) + expect(jointB!.roofExtension).toBeLessThan(0) + expect(jointA!.beamExtension).toBeLessThan(0) + expect(jointB!.beamExtension).toBeLessThan(0) + const seamA = jointA?.seam?.map((point) => cornerPlanPointToWorld(wallA, leanToA, point)) + const seamB = jointB?.seam?.map((point) => cornerPlanPointToWorld(wallB, leanToB, point)) + expect(seamA).toHaveLength(2) + expect(seamB).toHaveLength(2) + expect(pointSetHausdorffDistance(seamA!, seamB!)).toBeLessThan(1e-5) + } + }) + + test('gives unequal inner roofs one coincident valley seam', () => { + const fixture = innerCornerFixture() + const leanToB = LeanToExtensionNode.parse({ + ...fixture.leanToB, + highEdgeHeight: 3.1, + pitch: 16, + }) + const nodes = { ...fixture.nodes, [leanToB.id]: leanToB } + const jointA = resolveLeanToCornerJoints(fixture.leanToA, fixture.wallA, nodes).right + const jointB = resolveLeanToCornerJoints(leanToB, fixture.wallB, nodes).left + const seamA = jointA?.seam?.map((point) => + cornerPlanPointToWorld(fixture.wallA, fixture.leanToA, point), + ) + const seamB = jointB?.seam?.map((point) => + cornerPlanPointToWorld(fixture.wallB, leanToB, point), + ) + + expect(seamA).toHaveLength(2) + expect(seamB).toHaveLength(2) + expect(pointSetHausdorffDistance(seamA!, seamB!)).toBeLessThan(1e-5) + }) + + test('extends both roofs to one curved-to-straight low corner', () => { + const curvedWall = WallNode.parse({ + id: 'wall_curved_miter', + parentId: 'level_curved_miter', + start: [0, 0], + end: [6, 0], + curveOffset: -0.5, + }) + const straightWall = WallNode.parse({ + id: 'wall_straight_miter', + parentId: 'level_curved_miter', + start: [6, 0], + end: [6, -6], + }) + const curved = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(curvedWall, getWallCurveLength(curvedWall) / 2, 'front')!, + curvedWall, + ), + id: 'leanto_curved_miter', + } + const straight = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(straightWall, 3, 'front')!, + straightWall, + ), + id: 'leanto_straight_miter', + } + const nodes = Object.fromEntries( + [curvedWall, straightWall, curved, straight].map((node) => [node.id, node]), + ) as Record + + const joint = resolveLeanToCornerJoints(straight, straightWall, nodes).left + const reciprocal = resolveLeanToCornerJoints(curved, curvedWall, nodes).right + const straightSeam = joint?.seam?.map((point) => + cornerPlanPointToWorld(straightWall, straight, point), + ) + const curvedSeam = reciprocal?.seam?.map((point) => + cornerPlanPointToWorld(curvedWall, curved, point), + ) + + expect(joint?.roofExtension).toBeCloseTo(1.811, 2) + expect(joint?.gutterMitre).toBeCloseTo(0.577309, 5) + expect(joint?.roofPiece).toHaveLength(3) + expect(reciprocal?.roofPiece).toHaveLength(3) + expect(straightSeam).toHaveLength(2) + expect(curvedSeam).toHaveLength(2) + expect(pointSetHausdorffDistance(straightSeam!, curvedSeam!)).toBeLessThan(1e-5) + + const straightAssembly = createLeanToAssembly(straight, undefined, nodes) + const straightGeometry = generateRoofSegmentGeometry(straightAssembly.segment).applyMatrix4( + segmentWorldMatrix(straightWall, straight, straightAssembly.segment), + ) + straightGeometry.dispose() + + const curvedAssembly = createLeanToAssembly(curved, undefined, nodes) + const curvedGeometry = generateRoofSegmentGeometry(curvedAssembly.segment).applyMatrix4( + segmentWorldMatrix(curvedWall, curved, curvedAssembly.segment), + ) + expect( + new THREE.Box3().setFromBufferAttribute(curvedGeometry.getAttribute('position')).max.x, + ).toBeLessThan(8.81) + curvedGeometry.dispose() + }) + + test('auto-connects a shallow slanted shed to a curved shed using the gutter chord angle', () => { + const curvedWall = WallNode.parse({ + id: 'wall_curved_shallow_corner', + parentId: 'level_curved_shallow_corner', + start: [0, 0], + end: [6, 0], + curveOffset: -0.5, + }) + const straightWall = WallNode.parse({ + id: 'wall_straight_shallow_corner', + parentId: 'level_curved_shallow_corner', + start: [6, 0], + end: [6 + 6 / Math.sqrt(2), -6 / Math.sqrt(2)], + }) + const curved = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(curvedWall, getWallCurveLength(curvedWall) / 2, 'front')!, + curvedWall, + ), + id: 'leanto_curved_shallow_corner', + } + const straight = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(straightWall, 3, 'front')!, + straightWall, + ), + id: 'leanto_straight_shallow_corner', + } + const nodes = Object.fromEntries( + [curvedWall, straightWall, curved, straight].map((node) => [node.id, node]), + ) as Record + + const curvedJoint = resolveLeanToCornerJoints(curved, curvedWall, nodes).right + const straightJoint = resolveLeanToCornerJoints(straight, straightWall, nodes).left + const curvedSeam = curvedJoint?.seam?.map((point) => + cornerPlanPointToWorld(curvedWall, curved, point), + ) + const straightSeam = straightJoint?.seam?.map((point) => + cornerPlanPointToWorld(straightWall, straight, point), + ) + + expect(curvedJoint?.neighborId).toBe(straight.id) + expect(straightJoint?.neighborId).toBe(curved.id) + expect(curvedJoint?.gutterMitre).toBeCloseTo(0.213267, 5) + expect(straightJoint?.gutterMitre).toBeCloseTo(0.213267, 5) + expect(curvedSeam).toHaveLength(2) + expect(straightSeam).toHaveLength(2) + expect(pointSetHausdorffDistance(curvedSeam!, straightSeam!)).toBeLessThan(1e-5) + expect(curvedJoint?.roofPiece).toHaveLength(3) + expect(straightJoint?.roofPiece).toHaveLength(3) + + const curvedAssembly = createLeanToAssembly(curved, undefined, nodes) + const straightAssembly = createLeanToAssembly(straight, undefined, nodes) + const curvedGeometry = generateRoofSegmentGeometry(curvedAssembly.segment).applyMatrix4( + segmentWorldMatrix(curvedWall, curved, curvedAssembly.segment), + ) + const straightGeometry = generateRoofSegmentGeometry(straightAssembly.segment).applyMatrix4( + segmentWorldMatrix(straightWall, straight, straightAssembly.segment), + ) + const roofMeshes = [new THREE.Mesh(curvedGeometry), new THREE.Mesh(straightGeometry)] + const expectedMeshes = [ + new THREE.Mesh( + generateRoofSegmentGeometry({ + ...curvedAssembly.segment, + shedFootprintPieces: [], + }).applyMatrix4(segmentWorldMatrix(curvedWall, curved, curvedAssembly.segment)), + ), + new THREE.Mesh( + generateRoofSegmentGeometry({ + ...straightAssembly.segment, + shedFootprintPieces: [], + }).applyMatrix4(segmentWorldMatrix(straightWall, straight, straightAssembly.segment)), + ), + ] + const bounds = new THREE.Box3().setFromObject(expectedMeshes[0]!) + bounds.union(new THREE.Box3().setFromObject(expectedMeshes[1]!)) + const raycaster = new THREE.Raycaster() + raycaster.ray.direction.set(0, -1, 0) + const uncovered: [number, number][] = [] + const overlaps: [number, number][] = [] + for (let x = bounds.min.x + 0.027; x < bounds.max.x; x += 0.05) { + for (let z = bounds.min.z + 0.033; z < bounds.max.z; z += 0.05) { + if (x < 5.8 || z > 2) continue + raycaster.ray.origin.set(x, 10, z) + const expected = expectedMeshes.some( + (mesh) => raycaster.intersectObject(mesh, false).length > 0, + ) + if (!expected) continue + const owners = roofMeshes.filter( + (mesh) => raycaster.intersectObject(mesh, false).length > 0, + ).length + if (owners === 0) uncovered.push([x, z]) + if (owners > 1) overlaps.push([x, z]) + } + } + const curvedToStraightDistance = closestMeshDistance(curvedGeometry, straightGeometry) + const straightToCurvedDistance = closestMeshDistance(straightGeometry, curvedGeometry) + expect(Math.min(curvedToStraightDistance, straightToCurvedDistance)).toBeLessThan(0.02) + expect(contactingVertices(curvedGeometry, straightGeometry).length).toBeGreaterThan(2) + expect(contactingVertices(straightGeometry, curvedGeometry).length).toBeGreaterThan(2) + expect(uncovered).toEqual([]) + expect(overlaps).toEqual([]) + const curvedGutter = gutterWorldGeometry( + curvedWall, + curved, + curvedAssembly, + computeGutterMitres(curvedAssembly.gutter, curvedAssembly.segment, [ + { gutter: straightAssembly.gutter, segment: straightAssembly.segment }, + ]), + ) + const straightGutter = gutterWorldGeometry( + straightWall, + straight, + straightAssembly, + computeGutterMitres(straightAssembly.gutter, straightAssembly.segment, [ + { gutter: curvedAssembly.gutter, segment: curvedAssembly.segment }, + ]), + ) + const curvedContacts = contactingVertices(curvedGutter, straightGutter) + const straightContacts = contactingVertices(straightGutter, curvedGutter) + const lowRoofCorner = curvedSeam![1]! + const roofToGutterCorner = Math.min( + ...[...curvedContacts, ...straightContacts].map((point) => + Math.hypot(point[0]! - lowRoofCorner.x, point[2]! - lowRoofCorner.z), + ), + ) + expect(curvedContacts.length).toBeGreaterThan(10) + expect(straightContacts.length).toBeGreaterThan(10) + expect(roofToGutterCorner).toBeLessThan(0.05) + const curvedEndProfile = boundaryVerticesNear(curvedGutter, lowRoofCorner, 0.3) + const straightEndProfile = boundaryVerticesNear(straightGutter, lowRoofCorner, 0.3) + expect(curvedEndProfile.length).toBeGreaterThan(10) + expect(straightEndProfile.length).toBeGreaterThan(10) + expect(pointSetHausdorffDistance(curvedEndProfile, straightEndProfile)).toBeLessThan(0.002) + curvedGeometry.dispose() + straightGeometry.dispose() + curvedGutter.dispose() + straightGutter.dispose() + for (const mesh of expectedMeshes) mesh.geometry.dispose() + }) + + test('resolves a reciprocal 60 degree corner with its true gutter mitre', () => { + const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(60) + + const jointA = resolveLeanToCornerJoints(leanToA, wallA, nodes).right + const jointB = resolveLeanToCornerJoints(leanToB, wallB, nodes).left + + expect(jointA?.neighborId).toBe(leanToB.id) + expect(jointB?.neighborId).toBe(leanToA.id) + expect(jointA?.neighborSide).toBe('left') + expect(jointB?.neighborSide).toBe('right') + expect(jointA?.gutterMitre).toBeCloseTo(Math.PI / 3, 8) + expect(jointB?.gutterMitre).toBeCloseTo(Math.PI / 3, 8) + expect(jointA?.roofExtension).toBeGreaterThan(0) + expect(jointB?.roofExtension).toBeGreaterThan(0) + }) + + test('resolves acute and obtuse corner angles without reverting to a 45 degree cut', () => { + for (const angle of [30, 45, 75, 105, 120, 135, 150]) { + const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(angle) + const jointA = resolveLeanToCornerJoints(leanToA, wallA, nodes).right + const jointB = resolveLeanToCornerJoints(leanToB, wallB, nodes).left + const expectedMitre = ((180 - angle) * Math.PI) / 360 + + expect(jointA?.neighborId).toBe(leanToB.id) + expect(jointB?.neighborId).toBe(leanToA.id) + expect(jointA?.gutterMitre).toBeCloseTo(expectedMitre, 8) + expect(jointB?.gutterMitre).toBeCloseTo(expectedMitre, 8) + expect(Number(jointA?.sharedPostOwner) + Number(jointB?.sharedPostOwner)).toBe(1) + const postA = cornerPlanPointToWorld(wallA, leanToA, [ + jointA!.sharedPostPosition[0], + jointA!.sharedPostPosition[2], + ]) + const postB = cornerPlanPointToWorld(wallB, leanToB, [ + jointB!.sharedPostPosition[0], + jointB!.sharedPostPosition[2], + ]) + expect(postA.distanceTo(postB)).toBeLessThan(1e-6) + } + }) + + test('keeps the complete roof, gutter, beam, and shared-post joint continuous at every supported angle', () => { + for (const angle of continuousSupportedAngles) { + const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(angle) + const jointA = resolveLeanToCornerJoints(leanToA, wallA, nodes).right + const jointB = resolveLeanToCornerJoints(leanToB, wallB, nodes).left + const expectedMitre = ((180 - angle) * Math.PI) / 360 + + expect(jointA?.gutterMitre).toBeCloseTo(expectedMitre, 8) + expect(jointB?.gutterMitre).toBeCloseTo(expectedMitre, 8) + expect(jointA?.roofPiece.length).toBeGreaterThanOrEqual(3) + expect(jointB?.roofPiece.length).toBeGreaterThanOrEqual(3) + expect(jointA?.beamExtension).toBeGreaterThan(0) + expect(jointB?.beamExtension).toBeGreaterThan(0) + + const seamA = jointA?.seam?.map((point) => cornerPlanPointToWorld(wallA, leanToA, point)) + const seamB = jointB?.seam?.map((point) => cornerPlanPointToWorld(wallB, leanToB, point)) + expect(seamA).toHaveLength(2) + expect(seamB).toHaveLength(2) + expect(pointSetHausdorffDistance(seamA!, seamB!)).toBeLessThan(1e-5) + + const postA = cornerPlanPointToWorld(wallA, leanToA, [ + jointA!.sharedPostPosition[0], + jointA!.sharedPostPosition[2], + ]) + const postB = cornerPlanPointToWorld(wallB, leanToB, [ + jointB!.sharedPostPosition[0], + jointB!.sharedPostPosition[2], + ]) + expect(postA.distanceTo(postB)).toBeLessThan(1e-6) + + const assemblyA = createLeanToAssembly(leanToA, undefined, nodes) + const assemblyB = createLeanToAssembly(leanToB, undefined, nodes) + const gutterA = gutterWorldGeometry( + wallA, + leanToA, + assemblyA, + computeGutterMitres(assemblyA.gutter, assemblyA.segment, [ + { gutter: assemblyB.gutter, segment: assemblyB.segment }, + ]), + ) + const gutterB = gutterWorldGeometry( + wallB, + leanToB, + assemblyB, + computeGutterMitres(assemblyB.gutter, assemblyB.segment, [ + { gutter: assemblyA.gutter, segment: assemblyA.segment }, + ]), + ) + expect(contactingVertices(gutterA, gutterB).length).toBeGreaterThan(10) + expect(contactingVertices(gutterB, gutterA).length).toBeGreaterThan(10) + expect( + [...assemblyA.posts, ...assemblyB.posts].filter((post) => { + const index = managedLeanToPostIndex(post) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }), + ).toHaveLength(1) + gutterA.dispose() + gutterB.dispose() + } + }) + + test('rejects corners immediately outside the supported 30 to 150 degree range', () => { + for (const angle of [20, 29.99, 150.01, 160]) { + const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(angle) + expect(resolveLeanToCornerJoints(leanToA, wallA, nodes)).toEqual({}) + expect(resolveLeanToCornerJoints(leanToB, wallB, nodes)).toEqual({}) + } + }) + + test('joins both rendered gutter shells across acute and obtuse corners', () => { + for (const angle of [30, 45, 60, 75, 105, 120, 135, 150]) { + const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(angle) + const assemblyA = createLeanToAssembly(leanToA, undefined, nodes) + const assemblyB = createLeanToAssembly(leanToB, undefined, nodes) + const mitresA = computeGutterMitres(assemblyA.gutter, assemblyA.segment, [ + { gutter: assemblyB.gutter, segment: assemblyB.segment }, + ]) + const mitresB = computeGutterMitres(assemblyB.gutter, assemblyB.segment, [ + { gutter: assemblyA.gutter, segment: assemblyA.segment }, + ]) + const gutterA = gutterWorldGeometry(wallA, leanToA, assemblyA, mitresA) + const gutterB = gutterWorldGeometry(wallB, leanToB, assemblyB, mitresB) + const contactsA = contactingVertices(gutterA, gutterB) + const contactsB = contactingVertices(gutterB, gutterA) + + expect(contactsA.length).toBeGreaterThan(10) + expect(contactsB.length).toBeGreaterThan(10) + gutterA.dispose() + gutterB.dispose() + } + }) + + test('gives unequal roofs one coincident world seam across supported angles', () => { + for (const angle of [30, 45, 60, 75, 105, 120, 135, 150]) { + const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(angle) + const jointA = resolveLeanToCornerJoints(leanToA, wallA, nodes).right! + const jointB = resolveLeanToCornerJoints(leanToB, wallB, nodes).left! + const seamA = jointA.seam?.map((point) => cornerPlanPointToWorld(wallA, leanToA, point)) + const seamB = jointB.seam?.map((point) => cornerPlanPointToWorld(wallB, leanToB, point)) + + expect(jointA.roofPiece.length).toBeGreaterThanOrEqual(3) + expect(jointB.roofPiece.length).toBeGreaterThanOrEqual(3) + expect(seamA).toHaveLength(2) + expect(seamB).toHaveLength(2) + expect(pointSetHausdorffDistance(seamA!, seamB!)).toBeLessThan(1e-5) + } + }) + + test('partitions the shared 60 degree roof-corner patch exactly once', () => { + const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(60) + const assemblies = [ + { wall: wallA, leanTo: leanToA, assembly: createLeanToAssembly(leanToA, undefined, nodes) }, + { wall: wallB, leanTo: leanToB, assembly: createLeanToAssembly(leanToB, undefined, nodes) }, + ] + const meshes = assemblies.map(({ wall, leanTo, assembly }) => { + const matrix = segmentWorldMatrix(wall, leanTo, assembly.segment) + return new THREE.Mesh(generateRoofSegmentGeometry(assembly.segment).applyMatrix4(matrix)) + }) + const expectedFootprints = assemblies.map(({ wall, leanTo, assembly }) => { + const matrix = segmentWorldMatrix(wall, leanTo, assembly.segment) + const halfWidth = assembly.segment.width / 2 + const halfDepth = assembly.segment.depth / 2 + return [ + new THREE.Vector3(-halfWidth, 0, -halfDepth).applyMatrix4(matrix), + new THREE.Vector3(halfWidth, 0, -halfDepth).applyMatrix4(matrix), + new THREE.Vector3(halfWidth, 0, halfDepth).applyMatrix4(matrix), + new THREE.Vector3(-halfWidth, 0, halfDepth).applyMatrix4(matrix), + ] + }) + const bounds = new THREE.Box3().setFromPoints(expectedFootprints.flat()) + const raycaster = new THREE.Raycaster() + raycaster.ray.direction.set(0, -1, 0) + const uncovered: [number, number][] = [] + const overlaps: [number, number][] = [] + for (let x = bounds.min.x + 0.037; x < bounds.max.x; x += 0.08) { + for (let z = bounds.min.z + 0.053; z < bounds.max.z; z += 0.08) { + if (!expectedFootprints.every((polygon) => pointInPolygon([x, z], polygon))) continue + raycaster.ray.origin.set(x, 10, z) + const owners = meshes.filter((mesh) => raycaster.intersectObject(mesh, false).length > 0) + if (owners.length === 0) uncovered.push([x, z]) + if (owners.length > 1) overlaps.push([x, z]) + } + } + + expect(uncovered).toEqual([]) + expect(overlaps).toEqual([]) + for (const mesh of meshes) mesh.geometry.dispose() + }) + + test('drives roof, gutters, beam support, and one shared pillar from one joint', () => { + const { wallA, wallB, leanToA, leanToB, nodes } = cornerFixture() + const jointsA = resolveLeanToCornerJoints(leanToA, wallA, nodes) + const jointsB = resolveLeanToCornerJoints(leanToB, wallB, nodes) + const jointA = jointsA.right! + const jointB = jointsB.left! + + expect(jointA.neighborId).toBe(leanToB.id) + expect(jointB.neighborId).toBe(leanToA.id) + expect(jointA.roofPiece.length).toBeGreaterThanOrEqual(3) + expect(jointB.roofPiece.length).toBeGreaterThanOrEqual(3) + expect(jointA.seam).not.toBeNull() + expect(jointB.seam).not.toBeNull() + expect(Number(jointA.sharedPostOwner) + Number(jointB.sharedPostOwner)).toBe(1) + + const assemblyA = createLeanToAssembly(leanToA, undefined, nodes) + const assemblyB = createLeanToAssembly(leanToB, undefined, nodes) + expect(assemblyA.segment.trim.backRightX).toBe(0) + expect(assemblyB.segment.trim.backLeftX).toBe(0) + expect(assemblyA.segment.shedFootprintPieces).toHaveLength(2) + expect(assemblyB.segment.shedFootprintPieces).toHaveLength(2) + expect(assemblyA.gutter.metadata).toMatchObject({ + leanToGutterMitres: { left: 0, right: Math.PI / 4 }, + }) + expect(assemblyB.gutter.metadata).toMatchObject({ + leanToGutterMitres: { left: Math.PI / 4, right: 0 }, + }) + const sharedPosts = [...assemblyA.posts, ...assemblyB.posts].filter((post) => { + const index = managedLeanToPostIndex(post) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }) + expect(sharedPosts).toHaveLength(1) + }) + + test('renders a continuous unequal-pitch L without detached rectangular strips', () => { + const { wallA, wallB, leanToA, leanToB, nodes } = cornerFixture() + const segmentA = createLeanToAssembly(leanToA, undefined, nodes).segment + const segmentB = createLeanToAssembly(leanToB, undefined, nodes).segment + const localGeometries = [ + generateRoofSegmentGeometry(segmentA), + generateRoofSegmentGeometry(segmentB), + ] + + assertTopGeometryFollowsRoofSlab(localGeometries[0]!, segmentA) + assertTopGeometryFollowsRoofSlab(localGeometries[1]!, segmentB) + expect(countTopMaterialVerticalTriangles(localGeometries[0]!)).toBe(0) + expect(countTopMaterialVerticalTriangles(localGeometries[1]!)).toBe(0) + + const meshes = [ + new THREE.Mesh( + localGeometries[0]!.clone().applyMatrix4(segmentWorldMatrix(wallA, leanToA, segmentA)), + ), + new THREE.Mesh( + localGeometries[1]!.clone().applyMatrix4(segmentWorldMatrix(wallB, leanToB, segmentB)), + ), + ] + const raycaster = new THREE.Raycaster() + raycaster.ray.direction.set(0, -1, 0) + const uncovered: [number, number][] = [] + const overlaps: [number, number][] = [] + const samples = new Map() + + for (let xIndex = 0; xIndex <= 23; xIndex++) { + const x = 4.15 + xIndex * 0.1 + for (let zIndex = 0; zIndex <= 23; zIndex++) { + const z = 0.15 + zIndex * 0.1 + raycaster.ray.origin.set(x, 10, z) + const hits = meshes.map((mesh) => raycaster.intersectObject(mesh, false)[0]) + const owners = hits.flatMap((hit, owner) => (hit ? [owner] : [])) + if (owners.length === 0) uncovered.push([x, z]) + if (owners.length > 1) overlaps.push([x, z]) + if (owners.length === 1) { + const owner = owners[0]! + samples.set(`${xIndex}:${zIndex}`, { owner, height: hits[owner]!.point.y }) + } + } + } + + let transitions = 0 + const separatedTransitions: number[] = [] + for (let xIndex = 0; xIndex <= 23; xIndex++) { + for (let zIndex = 0; zIndex <= 23; zIndex++) { + const sample = samples.get(`${xIndex}:${zIndex}`) + if (!sample) continue + for (const key of [`${xIndex + 1}:${zIndex}`, `${xIndex}:${zIndex + 1}`]) { + const neighbor = samples.get(key) + if (!neighbor || neighbor.owner === sample.owner) continue + transitions++ + const delta = Math.abs(neighbor.height - sample.height) + if (delta > 0.05) separatedTransitions.push(delta) + } + } + } + + expect(uncovered).toEqual([]) + expect(overlaps).toEqual([]) + expect(transitions).toBeGreaterThan(0) + expect(separatedTransitions).toEqual([]) + for (const geometry of localGeometries) geometry.dispose() + for (const mesh of meshes) mesh.geometry.dispose() + }) + + test('joins both rendered gutter shells at the corner', () => { + for (const [reverseWalls, sideOverhang] of [ + [false, 0], + [true, 0], + [false, 0.3], + [true, 0.3], + ] as const) { + const { wallA, wallB, leanToA, leanToB, nodes } = cornerFixture(reverseWalls, sideOverhang) + const assemblyA = createLeanToAssembly(leanToA, undefined, nodes) + const assemblyB = createLeanToAssembly(leanToB, undefined, nodes) + const mitresA = computeGutterMitres(assemblyA.gutter, assemblyA.segment, [ + { gutter: assemblyB.gutter, segment: assemblyB.segment }, + ]) + const mitresB = computeGutterMitres(assemblyB.gutter, assemblyB.segment, [ + { gutter: assemblyA.gutter, segment: assemblyA.segment }, + ]) + const gutterA = gutterWorldGeometry(wallA, leanToA, assemblyA, mitresA) + const gutterB = gutterWorldGeometry(wallB, leanToB, assemblyB, mitresB) + const distance = Math.min( + closestMeshDistance(gutterA, gutterB), + closestMeshDistance(gutterB, gutterA), + ) + const contactsA = contactingVertices(gutterA, gutterB) + const contactsB = contactingVertices(gutterB, gutterA) + + expect(distance).toBeLessThan(1e-4) + expect(contactsA.length).toBeGreaterThan(10) + expect(contactsB.length).toBeGreaterThan(10) + gutterA.dispose() + gutterB.dispose() + } + }) +}) diff --git a/packages/nodes/src/lean-to-extension/schema.ts b/packages/nodes/src/lean-to-extension/schema.ts new file mode 100644 index 0000000000..835b28137a --- /dev/null +++ b/packages/nodes/src/lean-to-extension/schema.ts @@ -0,0 +1 @@ +export { LeanToExtensionNode } from '@pascal-app/core' diff --git a/packages/nodes/src/lean-to-extension/slots.ts b/packages/nodes/src/lean-to-extension/slots.ts new file mode 100644 index 0000000000..310d7ec7e1 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/slots.ts @@ -0,0 +1,20 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type LeanToSlotId = 'flashing' | 'ledger' | 'beam' | 'framing' | 'posts' | 'footings' + +export const LEAN_TO_SLOT_DEFAULTS: Partial> = { + flashing: 'library:metal-steel', + posts: 'library:concrete-plaster', + footings: 'library:concrete-plaster', +} + +export function leanToSlots(): SlotDeclaration[] { + return [ + { slotId: 'flashing', label: 'Flashing', default: LEAN_TO_SLOT_DEFAULTS.flashing }, + { slotId: 'ledger', label: 'Ledger / high beam', default: LEAN_TO_SLOT_DEFAULTS.ledger }, + { slotId: 'beam', label: 'Low beam', default: LEAN_TO_SLOT_DEFAULTS.beam }, + { slotId: 'framing', label: 'Framing', default: LEAN_TO_SLOT_DEFAULTS.framing }, + { slotId: 'posts', label: 'Posts', default: LEAN_TO_SLOT_DEFAULTS.posts }, + { slotId: 'footings', label: 'Footings', default: LEAN_TO_SLOT_DEFAULTS.footings }, + ] +} diff --git a/packages/nodes/src/lean-to-extension/system.test.ts b/packages/nodes/src/lean-to-extension/system.test.ts new file mode 100644 index 0000000000..b1af5b0404 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/system.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + clearSceneHistory, + createSceneApi, + LeanToExtensionNode, + LevelNode, + type SceneCommit, + subscribeSceneCommits, + useScene, + WallNode, +} from '@pascal-app/core' +import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' +import { initializeLeanToExtensionSync } from './system' + +type RafFn = (callback: (time: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( + callback, +) => { + callback(0) + return 0 +} +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +let stopSync = () => {} + +describe('lean-to scene commit boundary', () => { + beforeEach(() => { + const level = LevelNode.parse({ id: 'level_lean_commit', level: 0 }) + const wall = WallNode.parse({ + id: 'wall_lean_commit', + parentId: level.id, + start: [0, 0], + end: [6, 0], + }) + const leanTo = LeanToExtensionNode.parse({ + id: 'leanto_commit', + parentId: wall.id, + autoSpan: false, + position: [3, 0, 0.05], + }) + const assembly = createLeanToAssembly(leanTo) + const nodes = Object.fromEntries( + [ + level, + { ...wall, children: [assembly.extension.id] }, + assembly.extension, + ...assembly.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + }) + + afterEach(() => stopSync()) + + test('includes a projection edit and managed roof resize in one commit', () => { + const commits: SceneCommit[] = [] + const stopCommits = subscribeSceneCommits((commit) => commits.push(commit)) + const leanTo = Object.values(useScene.getState().nodes).find( + (node): node is LeanToExtensionNode => node.type === 'lean-to-extension', + )! + const roof = useScene.getState().nodes[leanTo.children[0] as AnyNodeId]! + const segmentId = roof.type === 'roof' ? (roof.children[0] as AnyNodeId) : ('' as AnyNodeId) + + useScene.getState().updateNode(leanTo.id as AnyNodeId, { projection: 4 }) + + expect(commits).toHaveLength(1) + expect(commits[0]?.current.nodes[segmentId]?.type).toBe('roof-segment') + expect((commits[0]?.current.nodes[segmentId] as { depth: number }).depth).toBeCloseTo(4.27) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + stopCommits() + }) + + test('preserves managed post rotation while parent edits still update its height', () => { + const leanTo = Object.values(useScene.getState().nodes).find( + (node): node is LeanToExtensionNode => node.type === 'lean-to-extension', + )! + const post = leanTo.children + .map((childId) => useScene.getState().nodes[childId as AnyNodeId]) + .find((node): node is Extract => node?.type === 'column')! + + useScene.getState().updateNode(post.id as AnyNodeId, { + rotation: Math.PI, + supportStyle: 'k-brace', + }) + const rotatedPost = useScene.getState().nodes[post.id as AnyNodeId] as typeof post + expect(rotatedPost.rotation).toBe(Math.PI) + expect(rotatedPost.supportStyle).toBe('k-brace') + const heightBeforeParentEdit = rotatedPost.height + + useScene.getState().updateNode(leanTo.id as AnyNodeId, { projection: 4 }) + + const postAfterParentEdit = useScene.getState().nodes[post.id as AnyNodeId] as typeof post + expect(postAfterParentEdit.rotation).toBe(Math.PI) + expect(postAfterParentEdit.supportStyle).toBe('k-brace') + expect(postAfterParentEdit.height).not.toBe(heightBeforeParentEdit) + }) + + test('preserves the resolved free wall span across commit synchronization', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_shared_wall', level: 0 }) + const wall = WallNode.parse({ + id: 'wall_shared_span', + parentId: level.id, + start: [0, 0], + end: [6, 0], + }) + const existing = LeanToExtensionNode.parse({ + id: 'leanto_existing_span', + parentId: wall.id, + autoSpan: false, + position: [1, 0, 0.05], + span: 2, + leftOverhang: 0, + rightOverhang: 0, + }) + const candidate = LeanToExtensionNode.parse({ + id: 'leanto_remaining_span', + parentId: wall.id, + autoSpan: true, + position: [4, 0, 0.05], + span: 4, + leftOverhang: 0, + rightOverhang: 0, + }) + const existingAssembly = createLeanToAssembly(existing) + const candidateAssembly = createLeanToAssembly(candidate) + const nodes = Object.fromEntries( + [ + level, + { ...wall, children: [existing.id, candidate.id] }, + existingAssembly.extension, + ...existingAssembly.children, + candidateAssembly.extension, + ...candidateAssembly.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + useScene.getState().updateNode(candidate.id as AnyNodeId, { projection: 3 }) + + const committed = useScene.getState().nodes[candidate.id as AnyNodeId] + expect(committed?.type).toBe('lean-to-extension') + if (committed?.type !== 'lean-to-extension') return + expect(committed.position[0]).toBeCloseTo(4, 6) + expect(committed.span).toBeCloseTo(4, 6) + }) + + test('synchronizes a complete corner joint after two extensions become neighbors', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_corner_sync', level: 0 }) + const wallA = WallNode.parse({ + id: 'wall_corner_sync_a', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const wallB = WallNode.parse({ + id: 'wall_corner_sync_b', + parentId: level.id, + start: [4, 0], + end: [4, -4], + }) + const leanToA = LeanToExtensionNode.parse({ + id: 'leanto_corner_sync_a', + parentId: wallA.id, + position: [2, 0, 0.05], + span: 4, + leftOverhang: 0, + rightOverhang: 0, + }) + const leanToB = LeanToExtensionNode.parse({ + id: 'leanto_corner_sync_b', + parentId: wallB.id, + position: [2, 0, 0.05], + span: 4, + leftOverhang: 0, + rightOverhang: 0, + }) + const assemblyA = createLeanToAssembly(leanToA) + const assemblyB = createLeanToAssembly(leanToB) + const nodes = Object.fromEntries( + [ + { ...level, children: [wallA.id, wallB.id] }, + { ...wallA, children: [leanToA.id] }, + { ...wallB, children: [leanToB.id] }, + assemblyA.extension, + ...assemblyA.children, + assemblyB.extension, + ...assemblyB.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + const syncedNodes = useScene.getState().nodes + const syncedA = syncedNodes[leanToA.id as AnyNodeId] + const syncedB = syncedNodes[leanToB.id as AnyNodeId] + expect(syncedA?.type).toBe('lean-to-extension') + expect(syncedB?.type).toBe('lean-to-extension') + if (syncedA?.type !== 'lean-to-extension' || syncedB?.type !== 'lean-to-extension') return + expect(syncedA.rightEndCondition).toBe('joined') + expect(syncedB.leftEndCondition).toBe('joined') + expect(syncedA.metadata).toMatchObject({ + leanToCornerJoints: { right: { gutterMitre: Math.PI / 4 } }, + }) + expect(syncedB.metadata).toMatchObject({ + leanToCornerJoints: { left: { gutterMitre: Math.PI / 4 } }, + }) + + const roofA = syncedA.children + .map((id) => syncedNodes[id as AnyNodeId]) + .find((node) => node?.type === 'roof') + const segmentA = + roofA?.type === 'roof' + ? roofA.children + .map((id) => syncedNodes[id as AnyNodeId]) + .find((node) => node?.type === 'roof-segment') + : undefined + const gutterA = + segmentA?.type === 'roof-segment' + ? segmentA.children + .map((id) => syncedNodes[id as AnyNodeId]) + .find((node) => node?.type === 'gutter') + : undefined + expect(segmentA).toMatchObject({ shedOpenEndSides: ['right'] }) + expect(gutterA?.metadata).toMatchObject({ + leanToGutterMitres: { left: 0, right: Math.PI / 4 }, + }) + + const cornerPosts = [...syncedA.children, ...syncedB.children] + .map((id) => syncedNodes[id as AnyNodeId]) + .filter((node) => { + if (node?.type !== 'column') return false + const index = managedLeanToPostIndex(node) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }) + expect(cornerPosts).toHaveLength(1) + }) + + test('removes regular posts outside a synchronized internal L valley', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_inner_post_sync', level: 0 }) + const wallA = WallNode.parse({ + id: 'wall_inner_post_sync_a', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const wallB = WallNode.parse({ + id: 'wall_inner_post_sync_b', + parentId: level.id, + start: [4, 0], + end: [4, 4], + }) + const leanToA = LeanToExtensionNode.parse({ + id: 'leanto_inner_post_sync_a', + parentId: wallA.id, + position: [2, 0, 0.05], + span: 4, + }) + const leanToB = LeanToExtensionNode.parse({ + id: 'leanto_inner_post_sync_b', + parentId: wallB.id, + position: [2, 0, 0.05], + span: 4, + }) + const assemblyA = createLeanToAssembly(leanToA) + const assemblyB = createLeanToAssembly(leanToB) + const nodes = Object.fromEntries( + [ + { ...level, children: [wallA.id, wallB.id] }, + { ...wallA, children: [leanToA.id] }, + { ...wallB, children: [leanToB.id] }, + assemblyA.extension, + ...assemblyA.children, + assemblyB.extension, + ...assemblyB.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + const syncedNodes = useScene.getState().nodes + const regularIndexesA = (syncedNodes[leanToA.id as AnyNodeId]?.children ?? []) + .map((id) => syncedNodes[id as AnyNodeId]) + .filter((node) => node?.type === 'column') + .map((post) => managedLeanToPostIndex(post)) + const regularIndexesB = (syncedNodes[leanToB.id as AnyNodeId]?.children ?? []) + .map((id) => syncedNodes[id as AnyNodeId]) + .filter((node) => node?.type === 'column') + .map((post) => managedLeanToPostIndex(post)) + + expect(regularIndexesA).not.toContain(2) + expect(regularIndexesB).not.toContain(0) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/system.tsx b/packages/nodes/src/lean-to-extension/system.tsx new file mode 100644 index 0000000000..285727bd73 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/system.tsx @@ -0,0 +1,692 @@ +'use client' + +import type { + AnyNode, + AnyNodeId, + ColumnNode, + DownspoutNode, + GutterNode, + LeanToExtensionNode, + RoofNode, + RoofSegmentNode, + SceneApi, + WallNode, +} from '@pascal-app/core' +import { useEffect } from 'react' +import { bendLocalPoint } from './arc' +import { + createManagedLeanToCornerPost, + createManagedLeanToPost, + createManagedLeanToRoofAssembly, + isManagedLeanToNode, + isManagedLeanToPost, + type LeanToPostSide, + leanToCornerPostIndex, + leanToCornerPostLayoutPatch, + leanToDownspoutLayoutPatch, + leanToGutterLayoutPatch, + leanToPostLayoutPatch, + leanToRoofMaterialPatch, + leanToRoofSegmentLayoutPatch, + managedLeanToPostIndex, + managedLeanToPostSide, + resolveLeanToPostBaseY, + resolveLeanToPostBaseYAtLocalPosition, + resolveLeanToPostGutterSetback, + resolveLeanToPostIndexes, +} from './assembly' +import { + LEAN_TO_CORNER_JOINTS_KEY, + leanToCornerJointMetadata, + resolveLeanToCornerJoints, +} from './corner-joint' +import { LEAN_TO_EXTENSION_GEOMETRY_REVISION, resolveLeanToSpanArc } from './layout' +import { resolveLeanToEndAbutments } from './placement-validation' +import { + applyLeanToAvailableWallSpan, + applyLeanToRoofAttachment, + applyLeanToWallAutoSpan, + clearLeanToRoofAttachment, + resolveLeanToHostRoof, + resolveLeanToRoofAttachment, +} from './roof-attachment' + +const BROAD_LEAN_TO_DEPENDENCY_TYPES = new Set([ + 'site', + 'building', + 'level', + 'slab', + 'wall', + 'lean-to-extension', + 'roof', + 'roof-segment', +]) + +function affectedLeanToIds( + nodes: Readonly>, + previous: Readonly>, + changedIds: ReadonlySet, + leanToIds: ReadonlySet, +): Set { + const affected = new Set() + for (const id of changedIds) { + const candidate = nodes[id] ?? previous[id] + if (!candidate) continue + if (candidate.type === 'lean-to-extension') affected.add(id) + const managedBy = (candidate.metadata as Record | undefined)?.managedByLeanTo + if (typeof managedBy === 'string') affected.add(managedBy as AnyNodeId) + let parentId = candidate.parentId as AnyNodeId | null + const seen = new Set() + while (parentId && !seen.has(parentId)) { + seen.add(parentId) + const parent = nodes[parentId] ?? previous[parentId] + if (!parent) break + if (parent.type === 'lean-to-extension') { + affected.add(parent.id as AnyNodeId) + break + } + parentId = parent.parentId as AnyNodeId | null + } + if (BROAD_LEAN_TO_DEPENDENCY_TYPES.has(candidate.type)) { + for (const leanToId of leanToIds) affected.add(leanToId) + } + } + return affected +} + +function sameTuple(left: readonly number[], right: readonly number[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function postNeedsLayoutUpdate( + post: ColumnNode, + leanTo: LeanToExtensionNode, + index: number, + baseY: number, + gutterSetback: number, + side: LeanToPostSide, +) { + const expected = leanToPostLayoutPatch(leanTo, index, baseY, gutterSetback, side) + return postPatchNeedsLayoutUpdate(post, expected) +} + +function postPatchNeedsLayoutUpdate( + post: ColumnNode, + expected: ReturnType, +) { + return ( + !sameTuple(post.position, expected.position) || + post.height !== expected.height || + post.width !== expected.width || + post.depth !== expected.depth || + post.crossSection !== expected.crossSection || + post.baseStyle !== expected.baseStyle || + post.baseHeight !== expected.baseHeight || + post.baseWidthScale !== expected.baseWidthScale || + post.baseDepthScale !== expected.baseDepthScale || + JSON.stringify(post.slots) !== JSON.stringify(expected.slots) + ) +} + +function segmentNeedsLayoutUpdate( + segment: RoofSegmentNode, + leanTo: LeanToExtensionNode, + nodes: Record, +) { + const expected = leanToRoofSegmentLayoutPatch(leanTo, nodes) + return ( + !sameTuple(segment.position, expected.position) || + segment.rotation !== expected.rotation || + segment.roofType !== expected.roofType || + segment.width !== expected.width || + segment.depth !== expected.depth || + segment.wallHeight !== expected.wallHeight || + segment.pitch !== expected.pitch || + segment.wallThickness !== expected.wallThickness || + segment.deckThickness !== expected.deckThickness || + segment.shingleThickness !== expected.shingleThickness || + segment.overhang !== expected.overhang || + JSON.stringify(segment.arc) !== JSON.stringify(expected.arc) || + segment.shedSideInfillSpan !== expected.shedSideInfillSpan || + segment.shedSideInfillMinX !== expected.shedSideInfillMinX || + segment.shedSideInfillMaxX !== expected.shedSideInfillMaxX || + JSON.stringify(segment.shedFootprintPieces) !== JSON.stringify(expected.shedFootprintPieces) || + JSON.stringify(segment.shedOpenEndSides) !== JSON.stringify(expected.shedOpenEndSides) || + JSON.stringify(segment.trim) !== JSON.stringify(expected.trim) || + JSON.stringify(segment.metadata) !== JSON.stringify(expected.metadata) + ) +} + +function gutterNeedsLayoutUpdate( + gutter: GutterNode, + segment: RoofSegmentNode, + leanTo: LeanToExtensionNode, + nodes: Record, +) { + const expected = leanToGutterLayoutPatch(segment, leanTo, gutter, nodes) + return ( + !sameTuple(gutter.position, expected.position) || + gutter.rotation !== expected.rotation || + gutter.length !== expected.length || + JSON.stringify(gutter.arc) !== JSON.stringify(expected.arc) || + gutter.roofSegmentId !== expected.roofSegmentId || + gutter.visible !== expected.visible || + gutter.profile !== expected.profile || + gutter.size !== expected.size || + JSON.stringify(gutter.outlets) !== JSON.stringify(expected.outlets) || + JSON.stringify(gutter.metadata) !== JSON.stringify(expected.metadata) + ) +} + +function downspoutNeedsLayoutUpdate( + downspout: DownspoutNode, + gutter: GutterNode, + segment: RoofSegmentNode, + leanTo: LeanToExtensionNode, +) { + const expected = leanToDownspoutLayoutPatch(segment, gutter, leanTo, downspout) + return ( + downspout.diameter !== expected.diameter || + downspout.gutterId !== expected.gutterId || + downspout.lengthMode !== expected.lengthMode || + downspout.visible !== expected.visible || + downspout.outletId !== expected.outletId + ) +} + +// The ground beneath each post — its slab support or terrain height — feeds +// the post base Y but is not otherwise part of the lean-to's own fields, so +// terrain edits and slab moves would leave the reconcile signature unchanged +// and the posts stuck at a stale height. Folding the resolved base Ys into the +// signature makes those external changes trigger a re-reconcile. +function leanToGroundSignature( + leanTo: LeanToExtensionNode, + nodes: Record, +): number[] { + const parent = leanTo.parentId ? nodes[leanTo.parentId as AnyNodeId] : undefined + if (parent?.type !== 'wall') return [] + const wall = parent as WallNode + const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) + const sides: LeanToPostSide[] = + leanTo.highSideMode === 'independent-high-beam' ? ['low', 'high'] : ['low'] + const values: number[] = [] + for (const side of sides) { + for (const index of resolveLeanToPostIndexes(leanTo, cornerJoints, side)) { + values.push(resolveLeanToPostBaseY(leanTo, wall, nodes, index, side)) + } + } + for (const joint of Object.values(cornerJoints)) { + if (!joint?.sharedPostOwner) continue + const bent = bendLocalPoint(leanTo, joint.sharedPostPosition[0], joint.sharedPostPosition[2]) + values.push( + resolveLeanToPostBaseYAtLocalPosition(leanTo, wall, nodes, [ + bent.x, + joint.sharedPostPosition[1], + bent.y, + ]), + ) + } + return values.map((value) => Math.round(value * 1e5) / 1e5) +} + +function extensionSignature( + leanTo: LeanToExtensionNode, + hostRoof: RoofNode | undefined, + nodes: Record, +): string { + return JSON.stringify([ + leanToGroundSignature(leanTo, nodes), + leanTo.span, + leanTo.spanArcCenterZ, + leanTo.spanArcRadius, + leanTo.autoSpan, + leanTo.position, + leanTo.projection, + leanTo.highEdgeHeight, + leanTo.lowEdgeHeight, + leanTo.pitch, + leanTo.roofThickness, + leanTo.shingleThickness, + leanTo.highOverhang, + leanTo.lowOverhang, + leanTo.leftOverhang, + leanTo.rightOverhang, + leanTo.autoMiterCorners, + leanTo.coveringType, + leanTo.beamHeight, + leanTo.rafterHeight, + leanTo.rafterSpacing, + leanTo.rafterEndInset, + leanTo.postWidth, + leanTo.postDepth, + leanTo.postCount, + leanTo.postLayoutMode, + leanTo.postSpacing, + leanTo.postInset, + leanTo.postBracing, + leanTo.footingStyle, + leanTo.highSideMode, + leanTo.ledgerVerticalOffset, + leanTo.lowBeamInset, + leanTo.slots, + leanTo.connectionMode, + leanTo.hostRoofId, + leanTo.hostRoofSegmentId, + leanTo.hostRoofEdge, + leanTo.hostRoofEdgeRange, + leanTo.connectionOffset, + leanTo.connectionInset, + leanTo.matchHostRoofMaterial, + leanTo.matchHostRoofStructure, + leanTo.gutterEnabled, + leanTo.gutterProfile, + leanTo.gutterSize, + leanTo.downspoutEnabled, + leanTo.downspoutPosition, + hostRoof && leanTo.matchHostRoofMaterial !== false ? leanToRoofMaterialPatch(hostRoof) : null, + Object.values(nodes) + .filter((node) => node.type === 'lean-to-extension') + .map((node) => ({ + id: node.id, + parentId: node.parentId, + position: node.position, + rotation: node.rotation, + span: node.span, + projection: node.projection, + highEdgeHeight: node.highEdgeHeight, + pitch: node.pitch, + roofThickness: node.roofThickness, + shingleThickness: node.shingleThickness, + beamHeight: node.beamHeight, + rafterHeight: node.rafterHeight, + leftOverhang: node.leftOverhang, + rightOverhang: node.rightOverhang, + lowOverhang: node.lowOverhang, + autoMiterCorners: node.autoMiterCorners, + gutterEnabled: node.gutterEnabled, + })), + leanTo.children, + leanTo.children.map((childId) => { + const child = nodes[childId as AnyNodeId] + return child?.type === 'column' ? child : null + }), + ]) +} + +function attachmentNeedsUpdate(current: LeanToExtensionNode, next: LeanToExtensionNode): boolean { + return ( + current.connectionMode !== next.connectionMode || + current.hostRoofId !== next.hostRoofId || + current.hostRoofSegmentId !== next.hostRoofSegmentId || + current.hostRoofEdge !== next.hostRoofEdge || + !sameTuple(current.hostRoofEdgeRange ?? [], next.hostRoofEdgeRange ?? []) || + current.connectionInset !== next.connectionInset || + current.highEdgeHeight !== next.highEdgeHeight || + current.lowEdgeHeight !== next.lowEdgeHeight || + current.leftEndCondition !== next.leftEndCondition || + current.rightEndCondition !== next.rightEndCondition || + current.downspoutPosition !== next.downspoutPosition || + current.span !== next.span || + current.spanArcCenterZ !== next.spanArcCenterZ || + current.spanArcRadius !== next.spanArcRadius || + !sameTuple(current.position, next.position) || + current.roofThickness !== next.roofThickness || + current.shingleThickness !== next.shingleThickness || + JSON.stringify(current.metadata) !== JSON.stringify(next.metadata) + ) +} + +function roofNeedsMaterialUpdate(roof: RoofNode, hostRoof: RoofNode): boolean { + const expected = leanToRoofMaterialPatch(hostRoof) + return Object.entries(expected).some( + ([key, value]) => JSON.stringify(roof[key as keyof typeof expected]) !== JSON.stringify(value), + ) +} + +function resolveEffectiveLeanTo( + leanTo: LeanToExtensionNode, + nodes: Record, +): LeanToExtensionNode { + const parent = leanTo.parentId ? nodes[leanTo.parentId as AnyNodeId] : undefined + if (parent?.type !== 'wall') { + return leanTo.connectionMode === 'manual' ? leanTo : clearLeanToRoofAttachment(leanTo) + } + const wall = parent as WallNode + const wallSpanningLeanTo = applyLeanToWallAutoSpan(leanTo, wall) + const retained = + leanTo.hostRoofSegmentId && leanTo.hostRoofEdge + ? resolveLeanToRoofAttachment(wallSpanningLeanTo, wall, nodes, { + roofSegmentId: leanTo.hostRoofSegmentId, + edge: leanTo.hostRoofEdge, + }) + : null + const attachment = retained ?? resolveLeanToRoofAttachment(wallSpanningLeanTo, wall, nodes) + // Manual mode is an explicit user choice to detach from any roof; never + // magnetically reattach it (doing so silently flipped connectionMode back to + // 'auto' and overwrote the user's wall-side height). Auto mode still tracks + // the nearest matching roof edge. + const resolved = + leanTo.connectionMode === 'manual' + ? wallSpanningLeanTo + : attachment + ? applyLeanToRoofAttachment(leanTo, attachment) + : clearLeanToRoofAttachment(wallSpanningLeanTo) + const withoutStaleJointEnds = leanTo.autoMiterCorners + ? { + ...resolved, + leftEndCondition: + resolved.leftEndCondition === 'joined' ? 'open' : resolved.leftEndCondition, + rightEndCondition: + resolved.rightEndCondition === 'joined' ? 'open' : resolved.rightEndCondition, + } + : resolved + const available = applyLeanToAvailableWallSpan( + withoutStaleJointEnds, + wall, + nodes, + leanTo.position[0], + ) + const withAbutments = resolveLeanToEndAbutments(available, wall, nodes) + const joints = resolveLeanToCornerJoints(withAbutments, wall, nodes) + const spanArc = resolveLeanToSpanArc(wall, withAbutments) + return { + ...withAbutments, + spanArcCenterZ: spanArc?.centerZ, + spanArcRadius: spanArc?.radius, + leftEndCondition: joints.left ? 'joined' : withAbutments.leftEndCondition, + rightEndCondition: joints.right ? 'joined' : withAbutments.rightEndCondition, + metadata: { + ...(withAbutments.metadata && typeof withAbutments.metadata === 'object' + ? withAbutments.metadata + : {}), + [LEAN_TO_CORNER_JOINTS_KEY]: leanToCornerJointMetadata(joints), + }, + } +} + +export function initializeLeanToExtensionSync(sceneApi: SceneApi) { + const applyChanges = sceneApi.applyChanges + const subscribeNodes = sceneApi.subscribeNodes + if (!(applyChanges && subscribeNodes)) return () => {} + const signatures = new Map() + const leanToIds = new Set() + for (const node of Object.values(sceneApi.nodes())) { + if (node.type === 'lean-to-extension') leanToIds.add(node.id as AnyNodeId) + } + let syncing = false + const reconcile = (candidateIds: Iterable) => { + const nodes = sceneApi.nodes() as Record + + for (const id of candidateIds) { + const candidate = nodes[id] + if (candidate?.type !== 'lean-to-extension') { + signatures.delete(id) + leanToIds.delete(id) + continue + } + const leanTo = candidate + const effectiveLeanTo = resolveEffectiveLeanTo(leanTo, nodes) + const parent = leanTo.parentId ? nodes[leanTo.parentId as AnyNodeId] : undefined + const hostRoof = resolveLeanToHostRoof(effectiveLeanTo, nodes) + const signature = extensionSignature(effectiveLeanTo, hostRoof, nodes) + if (signatures.get(id) === signature) continue + + const managedPosts = new Map() + const duplicateIds: AnyNodeId[] = [] + let roof: RoofNode | undefined + for (const childId of leanTo.children) { + const child = nodes[childId as AnyNodeId] + if (!child) continue + if (child.type === 'roof' && isManagedLeanToNode(child, leanTo.id, 'roof')) { + roof ??= child + continue + } + if (child.type !== 'column' || !isManagedLeanToPost(child, leanTo.id)) continue + const index = managedLeanToPostIndex(child) + const side = managedLeanToPostSide(child) + const key = `${side}:${index}` + if (index === null || managedPosts.has(key)) { + duplicateIds.push(child.id as AnyNodeId) + } else { + managedPosts.set(key, child) + } + } + + const create: { node: AnyNode; parentId?: AnyNodeId }[] = [] + const update: { id: AnyNodeId; data: Partial }[] = [] + const remove = [...duplicateIds] + + if (attachmentNeedsUpdate(leanTo, effectiveLeanTo)) { + update.push({ + id, + data: { + connectionMode: effectiveLeanTo.connectionMode, + hostRoofId: effectiveLeanTo.hostRoofId, + hostRoofSegmentId: effectiveLeanTo.hostRoofSegmentId, + hostRoofEdge: effectiveLeanTo.hostRoofEdge, + hostRoofEdgeRange: effectiveLeanTo.hostRoofEdgeRange, + connectionInset: effectiveLeanTo.connectionInset, + highEdgeHeight: effectiveLeanTo.highEdgeHeight, + lowEdgeHeight: effectiveLeanTo.lowEdgeHeight, + leftEndCondition: effectiveLeanTo.leftEndCondition, + rightEndCondition: effectiveLeanTo.rightEndCondition, + downspoutPosition: effectiveLeanTo.downspoutPosition, + span: effectiveLeanTo.span, + spanArcCenterZ: effectiveLeanTo.spanArcCenterZ, + spanArcRadius: effectiveLeanTo.spanArcRadius, + position: effectiveLeanTo.position, + roofThickness: effectiveLeanTo.roofThickness, + shingleThickness: effectiveLeanTo.shingleThickness, + metadata: effectiveLeanTo.metadata, + } as Partial, + }) + } + + if (!roof) { + const assembly = createManagedLeanToRoofAssembly(effectiveLeanTo, hostRoof, nodes) + create.push( + { node: assembly.roof, parentId: leanTo.id }, + { node: assembly.segment, parentId: assembly.roof.id }, + { node: assembly.gutter, parentId: assembly.segment.id }, + { node: assembly.downspout, parentId: assembly.segment.id }, + ) + } else { + if ( + hostRoof && + effectiveLeanTo.matchHostRoofMaterial !== false && + roofNeedsMaterialUpdate(roof, hostRoof) + ) { + update.push({ + id: roof.id as AnyNodeId, + data: leanToRoofMaterialPatch(hostRoof) as Partial, + }) + } + const segment = roof.children + .map((childId) => nodes[childId as AnyNodeId]) + .find( + (child): child is RoofSegmentNode => + child?.type === 'roof-segment' && + isManagedLeanToNode(child, leanTo.id, 'roof-segment'), + ) + if (segment) { + const segmentPatch = leanToRoofSegmentLayoutPatch(effectiveLeanTo, nodes) + const expectedSegment = { + ...segment, + ...segmentPatch, + } as RoofSegmentNode + if (segmentNeedsLayoutUpdate(segment, effectiveLeanTo, nodes)) { + update.push({ + id: segment.id as AnyNodeId, + data: segmentPatch as Partial, + }) + } + const gutter = segment.children + .map((childId) => nodes[childId as AnyNodeId]) + .find( + (child): child is GutterNode => + child?.type === 'gutter' && isManagedLeanToNode(child, leanTo.id, 'gutter'), + ) + if (gutter) { + const gutterPatch = leanToGutterLayoutPatch( + expectedSegment, + effectiveLeanTo, + gutter, + nodes, + ) + const expectedGutter = { ...gutter, ...gutterPatch } as GutterNode + if (gutterNeedsLayoutUpdate(gutter, expectedSegment, effectiveLeanTo, nodes)) { + update.push({ + id: gutter.id as AnyNodeId, + data: gutterPatch as Partial, + }) + } + const downspout = segment.children + .map((childId) => nodes[childId as AnyNodeId]) + .find( + (child): child is DownspoutNode => + child?.type === 'downspout' && isManagedLeanToNode(child, leanTo.id, 'downspout'), + ) + if ( + downspout && + downspoutNeedsLayoutUpdate( + downspout, + expectedGutter, + expectedSegment, + effectiveLeanTo, + ) + ) { + update.push({ + id: downspout.id as AnyNodeId, + data: leanToDownspoutLayoutPatch( + expectedSegment, + expectedGutter, + effectiveLeanTo, + downspout, + ) as Partial, + }) + } + } + } + } + + const cornerJoints = + parent?.type === 'wall' ? resolveLeanToCornerJoints(effectiveLeanTo, parent, nodes) : {} + const postSides: LeanToPostSide[] = + effectiveLeanTo.highSideMode === 'independent-high-beam' ? ['low', 'high'] : ['low'] + const desiredPostKeys = new Set() + for (const side of postSides) { + for (const index of resolveLeanToPostIndexes(effectiveLeanTo, cornerJoints, side)) { + const key = `${side}:${index}` + desiredPostKeys.add(key) + const postBaseY = + parent?.type === 'wall' + ? resolveLeanToPostBaseY(effectiveLeanTo, parent, nodes, index, side) + : 0 + const current = managedPosts.get(key) + const gutterSetback = + side === 'low' ? resolveLeanToPostGutterSetback(effectiveLeanTo, current) : 0 + if (!current) { + create.push({ + node: { + ...createManagedLeanToPost(effectiveLeanTo, index, side), + ...leanToPostLayoutPatch(effectiveLeanTo, index, postBaseY, gutterSetback, side), + } as ColumnNode, + parentId: leanTo.id, + }) + } else if ( + postNeedsLayoutUpdate(current, effectiveLeanTo, index, postBaseY, gutterSetback, side) + ) { + // Post rotation is user-owned once placed (the arc yaw is applied + // only at create time), so the managed sync must not clobber it. + const { rotation: _rotation, ...postData } = leanToPostLayoutPatch( + effectiveLeanTo, + index, + postBaseY, + gutterSetback, + side, + ) + update.push({ + id: current.id as AnyNodeId, + data: postData as Partial, + }) + } + } + } + for (const joint of Object.values(cornerJoints)) { + if (!joint?.sharedPostOwner) continue + const index = leanToCornerPostIndex(joint.side) + const key = `low:${index}` + desiredPostKeys.add(key) + const bentCornerPost = bendLocalPoint( + effectiveLeanTo, + joint.sharedPostPosition[0], + joint.sharedPostPosition[2], + ) + const postBaseY = + parent?.type === 'wall' + ? resolveLeanToPostBaseYAtLocalPosition(effectiveLeanTo, parent, nodes, [ + bentCornerPost.x, + joint.sharedPostPosition[1], + bentCornerPost.y, + ]) + : 0 + const current = managedPosts.get(key) + const gutterSetback = resolveLeanToPostGutterSetback(effectiveLeanTo, current) + const patch = leanToCornerPostLayoutPatch(effectiveLeanTo, joint, postBaseY, gutterSetback) + if (!current) { + create.push({ + node: { + ...createManagedLeanToCornerPost(effectiveLeanTo, joint), + ...patch, + } as ColumnNode, + parentId: leanTo.id, + }) + } else if (postPatchNeedsLayoutUpdate(current, patch)) { + update.push({ + id: current.id as AnyNodeId, + data: patch as Partial, + }) + } + } + for (const [key, post] of managedPosts) { + if (!desiredPostKeys.has(key)) remove.push(post.id as AnyNodeId) + } + + if (create.length > 0 || update.length > 0 || remove.length > 0) { + syncing = true + sceneApi.pauseHistory() + try { + applyChanges({ create, update, delete: remove }) + } finally { + sceneApi.resumeHistory() + syncing = false + } + } + signatures.set(id, signature) + } + } + + reconcile(leanToIds) + return subscribeNodes((nodes, previous, changedIds) => { + if (syncing) return + for (const id of changedIds) { + if (nodes[id]?.type === 'lean-to-extension') leanToIds.add(id) + } + const affected = affectedLeanToIds(nodes, previous, changedIds, leanToIds) + if (affected.size > 0) reconcile(affected) + }) +} + +const LeanToExtensionSystem = ({ sceneApi }: { sceneApi: SceneApi }) => { + useEffect(() => { + void LEAN_TO_EXTENSION_GEOMETRY_REVISION + for (const node of Object.values(sceneApi.nodes())) { + if (node.type === 'lean-to-extension') sceneApi.markDirty(node.id as AnyNodeId) + } + return initializeLeanToExtensionSync(sceneApi) + }, [sceneApi]) + + return null +} + +export default LeanToExtensionSystem diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx new file mode 100644 index 0000000000..3f31e3860e --- /dev/null +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -0,0 +1,148 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + emitter, + getLevelElevations, + getWallBaseElevationForNodes, + type WallEvent, + type WallNode, +} from '@pascal-app/core' +import { + triggerSFX, + useEditor, + useInteractionScope, + useRegistryToolContext, +} from '@pascal-app/editor' +import { useEffect, useState } from 'react' +import { createLeanToAssembly } from './assembly' +import { leanToExtensionGeometryKey } from './geometry' +import { + leanToWallLocalPose, + resolveLeanToWallPlacement, + resolveLeanToWallSurfaceHit, +} from './layout' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' +import LeanToExtensionPreview from './preview' +import { + applyLeanToAvailableWallSpan, + applyLeanToRoofAttachment, + applyLeanToWallAutoSpan, + clearLeanToRoofAttachment, + resolveLeanToHostRoof, + resolveLeanToRoofAttachment, +} from './roof-attachment' +import type { LeanToExtensionNode } from './schema' + +type PreviewPose = { + node: LeanToExtensionNode + position: [number, number, number] + rotationY: number +} + +const LeanToExtensionTool = () => { + const { activeLevelId, sceneApi, selectNode } = useRegistryToolContext() + const viewMode = useEditor((state) => state.viewMode) + const [preview, setPreview] = useState(null) + + useEffect(() => { + if (!(activeLevelId && viewMode === '3d')) return + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'lean-to-extension' }) + + const resolveBaseY = (wall: WallNode) => { + const nodes = sceneApi.nodes() as Record + const levelY = wall.parentId ? (getLevelElevations(nodes).get(wall.parentId)?.baseY ?? 0) : 0 + return levelY + getWallBaseElevationForNodes(wall, nodes) + } + + const updateTarget = (event: WallEvent) => { + const hit = resolveLeanToWallSurfaceHit(event.node, event.localPosition, event.normal) + if (!hit) { + setPreview(null) + return null + } + const wallPlacement = resolveLeanToWallPlacement(event.node, hit.localX, hit.side) + if (!wallPlacement) { + setPreview(null) + return null + } + const nodes = sceneApi.nodes() as Record + const attachment = resolveLeanToRoofAttachment(wallPlacement, event.node, nodes) + const autoSpannedNode = attachment + ? applyLeanToRoofAttachment(wallPlacement, attachment) + : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), event.node) + const attachedNode = applyLeanToAvailableWallSpan( + autoSpannedNode, + event.node, + nodes, + wallPlacement.position[0], + ) + const node = resolveLeanToEndAbutments(attachedNode, event.node, nodes) + if (leanToPlacementConflicts(node, event.node, nodes).length > 0) { + setPreview(null) + return null + } + const pose = leanToWallLocalPose(event.node, node, resolveBaseY(event.node)) + setPreview((current) => ({ + node: + current && leanToExtensionGeometryKey(current.node) === leanToExtensionGeometryKey(node) + ? current.node + : node, + ...pose, + })) + return node + } + + const onWallMove = (event: WallEvent) => { + updateTarget(event) + } + const onWallLeave = () => { + setPreview(null) + } + const onWallClick = (event: WallEvent) => { + const node = updateTarget(event) + if (!node) return + event.stopPropagation() + const nodes = sceneApi.nodes() as Record + const assembly = createLeanToAssembly(node, resolveLeanToHostRoof(node, nodes), nodes) + sceneApi.createMany?.([ + { node: assembly.extension, parentId: event.node.id }, + ...assembly.children.map((child) => ({ + node: child, + parentId: (child.parentId as AnyNodeId | null) ?? undefined, + })), + ]) + selectNode(assembly.extension.id as AnyNodeId) + triggerSFX('sfx:structure-build') + if (useEditor.getState().getContinuation('point') !== 'repeat') { + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + } + } + + emitter.on('wall:move', onWallMove) + emitter.on('wall:enter', onWallMove) + emitter.on('wall:leave', onWallLeave) + emitter.on('wall:click', onWallClick) + return () => { + emitter.off('wall:move', onWallMove) + emitter.off('wall:enter', onWallMove) + emitter.off('wall:leave', onWallLeave) + emitter.off('wall:click', onWallClick) + setPreview(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'drafting' && scope.tool === 'lean-to-extension') + } + }, [activeLevelId, sceneApi, selectNode, viewMode]) + + if (!preview || viewMode !== '3d') return null + return ( + + + + ) +} + +export default LeanToExtensionTool diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index 949b187fcb..bfb366d940 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -205,7 +205,7 @@ export const ridgeVentDefinition: NodeDefinition = { presentation: { label: 'Ridge Vent', description: 'Ventilation strip running along the ridge of a roof segment.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/ridge-vent.webp' }, paletteSection: 'structure', paletteOrder: 121, }, diff --git a/packages/nodes/src/roof-segment/definition.test.ts b/packages/nodes/src/roof-segment/definition.test.ts new file mode 100644 index 0000000000..42812c0575 --- /dev/null +++ b/packages/nodes/src/roof-segment/definition.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test' +import { + getActiveRoofHeight, + type HandleDescriptor, + type LinearResizeHandle, + type RoofSegmentNode, +} from '@pascal-app/core' +import { roofSegmentDefinition } from './definition' + +function segment(overrides: Partial = {}): RoofSegmentNode { + return { + object: 'node', + id: 'rseg_test', + type: 'roof-segment', + parentId: null, + visible: true, + metadata: {}, + position: [10, 0, 20], + rotation: 0, + roofType: 'shed', + width: 8, + depth: 6, + wallHeight: 2.5, + pitch: 30, + 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 +} + +function handles(node: RoofSegmentNode = segment()): HandleDescriptor[] { + const descriptors = roofSegmentDefinition.handles + return ( + typeof descriptors === 'function' ? descriptors(node, undefined as never) : descriptors + ) as HandleDescriptor[] +} + +function linear(axis: 'x' | 'z', anchor: 'min' | 'max'): LinearResizeHandle { + const handle = handles().find( + (h): h is LinearResizeHandle => + h.kind === 'linear-resize' && h.axis === axis && h.anchor === anchor, + ) + if (!handle) throw new Error(`Missing ${axis}/${anchor} handle`) + return handle +} + +function pitchHandle(): LinearResizeHandle { + const handle = handles().find( + (h): h is LinearResizeHandle => + h.kind === 'linear-resize' && h.axis === 'y' && typeof h.min === 'function', + ) + if (!handle) throw new Error('Missing pitch handle') + return handle +} + +describe('roof-segment resize handles', () => { + test('place shed side handles at roof level', () => { + const node = segment() + const roofHeight = getActiveRoofHeight(node) + + expect(linear('x', 'min').placement.position(node, undefined as never)[1]).toBeCloseTo( + node.wallHeight + roofHeight / 2 + 0.15, + ) + expect(linear('z', 'min').placement.position(node, undefined as never)[1]).toBeCloseTo( + node.wallHeight + 0.15, + ) + expect(linear('z', 'max').placement.position(node, undefined as never)[1]).toBeCloseTo( + node.wallHeight + roofHeight + 0.15, + ) + }) + + test('right and left width handles resize only the dragged side', () => { + const node = segment() + const rightPatch = linear('x', 'min').apply(node, 10, undefined as never) + const leftPatch = linear('x', 'max').apply(node, 10, undefined as never) + + expect(rightPatch).toMatchObject({ width: 10, position: [11, 0, 20] }) + expect(leftPatch).toMatchObject({ width: 10, position: [9, 0, 20] }) + }) + + test('front and back depth handles resize only the dragged side', () => { + const node = segment() + const frontPatch = linear('z', 'min').apply(node, 8, undefined as never) + const backPatch = linear('z', 'max').apply(node, 8, undefined as never) + + expect(frontPatch).toMatchObject({ depth: 8, position: [10, 0, 21] }) + expect(backPatch).toMatchObject({ depth: 8, position: [10, 0, 19] }) + }) + + test('hides the pitch handle for managed lean-to roof segments', () => { + const handle = pitchHandle() + const managed = segment({ + metadata: { + managedByLeanTo: 'lean_to_test', + leanToRole: 'roof-segment', + }, + }) + + expect(handle.visible?.(segment(), undefined as never)).not.toBe(false) + expect(handle.visible?.(managed, undefined as never)).toBe(false) + }) + + test('hides all direct handles for managed lean-to roof segments', () => { + expect( + handles( + segment({ + metadata: { + managedByLeanTo: 'lean_to_test', + leanToRole: 'roof-segment', + }, + }), + ), + ).toEqual([]) + }) +}) diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 2045e41482..1ded893143 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -18,6 +18,7 @@ import { RoofSegmentNode } from './schema' const SIDE_HANDLE_OFFSET = 0.3 const HEIGHT_HANDLE_OFFSET = 0.3 +const ROOF_HANDLE_CLEARANCE = 0.15 const ROTATE_CORNER_OFFSET = 0.4 const ROTATE_RING_OFFSET = 0.08 const MIN_ROOF_DIM = 1 @@ -36,6 +37,23 @@ function getPeakHeight(n: RoofSegmentNodeType): number { return n.wallHeight + getActiveRoofHeight(n) } +function isManagedLeanToRoofSegment(n: RoofSegmentNodeType): boolean { + const metadata = n.metadata + if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return false + const record = metadata as Record + return record.managedByLeanTo !== undefined && record.leanToRole === 'roof-segment' +} + +function getSideResizeHandleY(n: RoofSegmentNodeType, localZ: number): number { + if (n.roofType !== 'shed') return Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2 + + const halfDepth = Math.max(n.depth, MIN_ROOF_DIM) / 2 + const roofHeight = getActiveRoofHeight(n) + const t = halfDepth > 0 ? (localZ + halfDepth) / (2 * halfDepth) : 0.5 + const roofY = n.wallHeight + roofHeight * (1 - Math.max(0, Math.min(1, t))) + return Math.max(roofY, MIN_WALL_DISPLAY) + ROOF_HANDLE_CLEARANCE +} + // Width arrow on the +X (right) or -X (left) side. Asymmetric resize: // dragging one arrow grows the segment outward from its own edge while // the opposite edge stays world-fixed — the same pattern doors use @@ -73,11 +91,7 @@ function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor [ - sign * (n.width / 2 + SIDE_HANDLE_OFFSET), - Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2, - 0, - ], + position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), getSideResizeHandleY(n, 0), 0], // Flip the left chevron so it points outward toward -X. The // generic LinearArrow only auto-orients for axis 'z' (rotates the // chevron 90° to face +Z); +X / -X facing is up to the descriptor. @@ -141,7 +155,7 @@ function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor [ 0, - Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2, + getSideResizeHandleY(n, sign * (n.depth / 2)), sign * (n.depth / 2 + SIDE_HANDLE_OFFSET), ], // For axis 'z', `LinearArrow` adds -π/2 around Y so the chevron @@ -167,6 +181,7 @@ function roofSegmentWallHeightHandle(): HandleDescriptor { anchor: 'min', shape: 'tracker', min: MIN_WALL_HEIGHT, + gridSnap: true, currentValue: (n) => n.wallHeight, apply: (_n, newValue) => ({ wallHeight: newValue }), placement: { @@ -193,7 +208,9 @@ function roofSegmentPitchHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: (n) => n.wallHeight, + gridSnap: true, currentValue: (n) => getPeakHeight(n), + visible: (n) => !isManagedLeanToRoofSegment(n), apply: (initial, newPeakHeight) => { const roofHeight = Math.max(0, newPeakHeight - initial.wallHeight) const pitch = getPitchFromActiveRoofHeight({ @@ -256,6 +273,12 @@ const roofSegmentHandles: HandleDescriptor[] = [ roofSegmentRotateHandle(), ] +function resolveRoofSegmentHandles( + node: RoofSegmentNodeType, +): HandleDescriptor[] { + return isManagedLeanToRoofSegment(node) ? [] : roofSegmentHandles +} + /** * Roof segment — Stage A. Child of a roof node, owns the per-segment * polygon + pitch. Geometry is generated by `RoofSystem` (registered @@ -297,7 +320,7 @@ export const roofSegmentDefinition: NodeDefinition = { }, parametrics: roofSegmentParametrics, - handles: roofSegmentHandles, + handles: resolveRoofSegmentHandles, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.ts b/packages/nodes/src/roof-segment/floorplan-affordances.ts index f0f5658bed..70fa8ecd0d 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -53,18 +53,21 @@ function resolveSegmentFrame( /** * Roof-segment width / depth drag (floor-plan). Mirrors the 3D - * `linear-resize` handles in `definition.ts` — `anchor: 'center'` - * means dragging outward on either +/-X (or +/-Z) edge grows the - * dimension by 2× the segment-local cursor offset while the segment's - * roof-local position stays put. Projects the plan cursor onto the - * segment's effective rotation (roof.rotation + segment.rotation) so - * the math survives any parent-roof rotation. + * `linear-resize` handles in `definition.ts`: the dragged side moves + * while the opposite side stays fixed. Projects the plan cursor onto + * the segment's effective rotation (roof.rotation + segment.rotation) + * so the math survives any parent-roof rotation, then writes the + * corresponding roof-local center shift alongside the new dimension. */ export const roofSegmentResizeAffordance: FloorplanAffordance = { start({ node, payload, nodes, initialPlanPoint }) { const { axis, side } = payload as RoofSegmentResizePayload const segmentId = node.id as AnyNodeId const initialValue = axis === 'x' ? node.width : node.depth + const initialPosition = node.position + const segmentRotation = node.rotation ?? 0 + const armX = axis === 'x' ? Math.cos(segmentRotation) : Math.sin(segmentRotation) + const armZ = axis === 'x' ? -Math.sin(segmentRotation) : Math.cos(segmentRotation) const { cx, cz, effRot } = resolveSegmentFrame(node, nodes) const cosEff = Math.cos(effRot) const sinEff = Math.sin(effRot) @@ -84,17 +87,26 @@ export const roofSegmentResizeAffordance: FloorplanAffordance = apply({ planPoint }) { const currentLocal = projectLocalAxis(planPoint[0], planPoint[1]) const delta = (currentLocal - initialLocal) * side - const rawValue = initialValue + 2 * delta + const rawValue = initialValue + delta // Mode-aware grid step (0 outside grid mode, so `lines` / `off` resize // freely — the "smooth" behaviour that used to need a held Shift). The // reshaping scope opened by the dispatcher resolves the `polygon` set. const step = getSegmentGridStep() const snappedValue = step > 0 ? snapScalar(rawValue, step) : rawValue const newValue = Math.max(MIN_ROOF_DIM, snappedValue) + const centerOffset = (side * (newValue - initialValue)) / 2 + const position: [number, number, number] = [ + initialPosition[0] + centerOffset * armX, + initialPosition[1], + initialPosition[2] + centerOffset * armZ, + ] lastValue = newValue useLiveNodeOverrides .getState() - .set(segmentId, axis === 'x' ? { width: newValue } : { depth: newValue }) + .set( + segmentId, + axis === 'x' ? { width: newValue, position } : { depth: newValue, position }, + ) useScene.getState().markDirty(segmentId) }, canCommit() { @@ -102,9 +114,18 @@ export const roofSegmentResizeAffordance: FloorplanAffordance = }, commit() { useLiveNodeOverrides.getState().clear(segmentId) + const centerOffset = (side * (lastValue - initialValue)) / 2 + const position: [number, number, number] = [ + initialPosition[0] + centerOffset * armX, + initialPosition[1], + initialPosition[2] + centerOffset * armZ, + ] useScene .getState() - .updateNode(segmentId, axis === 'x' ? { width: lastValue } : { depth: lastValue }) + .updateNode( + segmentId, + axis === 'x' ? { width: lastValue, position } : { depth: lastValue, position }, + ) }, } }, diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index ed87ec213c..a88bd45f45 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, createDefaultRidgeVentsForSegment, + isAutoGutterEnabled, isAutoRidgeVentEnabled, isDefaultRidgeVentNode, ROOF_SHAPE_DEFAULTS, @@ -53,6 +54,11 @@ function shouldShowTrimPlanes(metadata: unknown): boolean { return metadataRecord(metadata).showTrimPlanes === true } +function isManagedLeanToRoofSegment(metadata: unknown): boolean { + const record = metadataRecord(metadata) + return record.managedByLeanTo !== undefined && record.leanToRole === 'roof-segment' +} + function metadataRecord(metadata: unknown): Record { if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) { return metadata as Record @@ -77,6 +83,13 @@ export default function RoofSegmentPanel() { if (current?.type !== 'roof-segment') return false return isAutoRidgeVentEnabled(current, s.nodes) }) + const autoGutterEnabled = useScene((s) => { + const current = selectedId + ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) + : undefined + if (current?.type !== 'roof-segment') return false + return isAutoGutterEnabled(current, s.nodes) + }) const handleUpdate = useCallback( (updates: Partial) => { @@ -88,6 +101,7 @@ export default function RoofSegmentPanel() { const handleRoofTypeChange = useCallback( (roofType: RoofType) => { + if (isManagedLeanToRoofSegment(node?.metadata)) return // 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. @@ -104,7 +118,7 @@ export default function RoofSegmentPanel() { : { roofType }, ) }, - [handleUpdate], + [handleUpdate, node?.metadata], ) const handleClose = useCallback(() => { @@ -205,9 +219,23 @@ export default function RoofSegmentPanel() { [selectedId], ) + const handleAutoGutterToggle = 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), autoGutter: checked }, + }) + }, + [selectedId], + ) + if (!(node && node.type === 'roof-segment' && selectedId)) return null const showTrimPlanes = shouldShowTrimPlanes(node.metadata) + const managedLeanToRoofSegment = isManagedLeanToRoofSegment(node.metadata) return ( handleRoofTypeChange(v)} options={ROOF_TYPE_OPTIONS} value={node.roofType} + disabled={managedLeanToRoofSegment} /> handleRoofTypeChange(v)} options={ROOF_TYPE_OPTIONS_2} value={node.roofType} + disabled={managedLeanToRoofSegment} /> @@ -249,6 +279,14 @@ export default function RoofSegmentPanel() { )} + + + + = {}): RoofNode { + return { + object: 'node', + id: 'roof_test', + type: 'roof', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + children: [], + ...overrides, + } as RoofNode +} + +function handles(node: RoofNode = roof()): HandleDescriptor[] { + const descriptors = roofDefinition.handles + return ( + typeof descriptors === 'function' ? descriptors(node, undefined as never) : descriptors + ) as HandleDescriptor[] +} + +describe('roof handles', () => { + test('hides direct move handle for managed lean-to roofs', () => { + expect(handles(roof()).length).toBeGreaterThan(0) + expect( + handles( + roof({ + metadata: { + managedByLeanTo: 'lean_to_test', + leanToRole: 'roof', + }, + }), + ), + ).toEqual([]) + }) +}) diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index 17e514d646..ab2130b433 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -79,6 +79,17 @@ function roofMoveHandle(): HandleDescriptor { const roofHandles: HandleDescriptor[] = [roofMoveHandle()] +function isManagedLeanToRoof(node: RoofNodeType): boolean { + const metadata = node.metadata + if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return false + const record = metadata as Record + return record.managedByLeanTo !== undefined && record.leanToRole === 'roof' +} + +function resolveRoofHandles(node: RoofNodeType): HandleDescriptor[] { + return isManagedLeanToRoof(node) ? [] : roofHandles +} + /** * Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer` * + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` + @@ -168,7 +179,7 @@ export const roofDefinition: NodeDefinition = { }, parametrics: roofParametrics, - handles: roofHandles, + handles: resolveRoofHandles, floorplan: buildRoofFloorplan, renderer: { diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index 6b4b68218c..c33ddfbe2c 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -5,7 +5,6 @@ import { type AnyNodeId, type BoxVentNode, type ChimneyNode, - createDefaultRidgeVentsForSegment, type DormerNode, type GutterNode, type RidgeVentNode, @@ -176,15 +175,9 @@ export default function RoofPanel() { pitch: 40, roofType: 'gable', position: [2, 0, 2], + metadata: { autoRidgeVent: false }, }) - const ridgeVents = createDefaultRidgeVentsForSegment(segment) - createNodes([ - { node: segment, parentId: node.id as AnyNodeId }, - ...ridgeVents.map((ridgeVent) => ({ - node: ridgeVent, - parentId: segment.id as AnyNodeId, - })), - ]) + createNodes([{ node: segment, parentId: node.id as AnyNodeId }]) }, [node, createNodes]) const handleSelectSegment = useCallback( diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index f76dc610b1..1a3a12d42a 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -272,7 +272,7 @@ export const skylightDefinition: NodeDefinition = { presentation: { label: 'Skylight', description: 'Framed glass opening on a roof segment.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/skylight.webp' }, paletteSection: 'structure', paletteOrder: 124, }, diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index 6433403a8b..8aece59b78 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -273,7 +273,7 @@ export const solarPanelDefinition: NodeDefinition = { presentation: { label: 'Solar Panel', description: 'Grid of photovoltaic panels mounted on a roof segment.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/solar-panel.webp' }, paletteSection: 'structure', paletteOrder: 123, }, diff --git a/packages/nodes/src/turbine-vent/definition.ts b/packages/nodes/src/turbine-vent/definition.ts index 060d15c9ad..995d9618cc 100644 --- a/packages/nodes/src/turbine-vent/definition.ts +++ b/packages/nodes/src/turbine-vent/definition.ts @@ -126,7 +126,7 @@ export const turbineVentDefinition: NodeDefinition = { presentation: { label: 'Turbine Vent', description: 'Wind-driven spinning whirlybird exhaust vent for a roof slope.', - icon: { kind: 'url', src: '/icons/roof.webp' }, + icon: { kind: 'url', src: '/icons/turbine-vent.webp' }, paletteSection: 'structure', paletteOrder: 121, }, diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index ab9f3587c4..bca76db924 100644 --- a/packages/nodes/src/wall/definition.test.ts +++ b/packages/nodes/src/wall/definition.test.ts @@ -3,8 +3,8 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core' import { getFloorplanNodeExtension } from '@pascal-app/editor' import { wallDefinition } from './definition' -test('wallDefinition records the retired assembly field migration', () => { - expect(wallDefinition.schemaVersion).toBe(7) +test('wallDefinition records the lean-to child schema migration', () => { + expect(wallDefinition.schemaVersion).toBe(8) }) describe('wallDefinition floor-plan extension', () => { diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 71d4b265d3..69a0a8b4d5 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -40,7 +40,7 @@ import { wallSlots } from './slots' export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', - schemaVersion: 7, + schemaVersion: 8, schema: WallNode, category: 'structure', surfaceRole: 'wall', @@ -52,7 +52,13 @@ export const wallDefinition: NodeDefinition = { !node.children.some((childId) => { const child = nodes[childId as AnyNodeId] if (!child) return false - if (child.type === 'door' || child.type === 'window') return true + if ( + child.type === 'door' || + child.type === 'window' || + child.type === 'lean-to-extension' + ) { + return true + } if (child.type !== 'item') return false return child.asset?.attachTo === 'wall' || child.asset?.attachTo === 'wall-side' }), @@ -104,7 +110,7 @@ export const wallDefinition: NodeDefinition = { }, relations: { - hosts: ['door', 'window', 'item'], + hosts: ['door', 'window', 'item', 'lean-to-extension'], affectsSpatial: ['slab', 'ceiling', 'zone'], linkedBy: 'endpoint-match', cascadeDelete: 'descendants', @@ -168,7 +174,8 @@ export const wallDefinition: NodeDefinition = { presentation: { label: 'Wall', - description: 'A straight or curved wall segment. Hosts doors, windows, and wall-mounted items.', + description: + 'A straight or curved wall segment. Hosts doors, windows, lean-to extensions, and wall-mounted items.', icon: { kind: 'url', src: '/icons/wall.webp' }, paletteSection: 'structure', paletteOrder: 10, diff --git a/packages/viewer/src/systems/roof/roof-system.test.ts b/packages/viewer/src/systems/roof/roof-system.test.ts new file mode 100644 index 0000000000..31c4f65b8a --- /dev/null +++ b/packages/viewer/src/systems/roof/roof-system.test.ts @@ -0,0 +1,250 @@ +// @ts-expect-error - bun:test is provided by the Bun runtime; viewer does not +// include Bun globals in its package tsconfig. +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import * as THREE from 'three' +import { generateRoofSegmentGeometry } from './roof-system' + +describe('roof system shed geometry', () => { + function inspectShedGeometry(segment: RoofSegmentNode) { + const geometry = generateRoofSegmentGeometry(segment) + const position = geometry.getAttribute('position') + const index = geometry.getIndex() + expect(index).not.toBeNull() + + const sideInfillX: number[] = [] + const sideInfillNormals: THREE.Vector3[] = [] + const roofSideX: number[] = [] + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const normal = new THREE.Vector3() + const edge = new THREE.Vector3() + + expect(geometry.groups.some((group) => group.materialIndex === 1)).toBe(false) + + for (const group of geometry.groups) { + for (let i = group.start; i < group.start + group.count; i += 3) { + const ia = index!.getX(i) + const ib = index!.getX(i + 1) + const ic = index!.getX(i + 2) + a.fromBufferAttribute(position, ia) + b.fromBufferAttribute(position, ib) + c.fromBufferAttribute(position, ic) + normal.subVectors(b, a).cross(edge.subVectors(c, a)).normalize() + + if (group.materialIndex === 0 || group.materialIndex === 3) { + roofSideX.push(Math.abs(a.x), Math.abs(b.x), Math.abs(c.x)) + } + + if (group.materialIndex === 2) { + sideInfillNormals.push(normal.clone()) + for (const vertexIndex of [ia, ib, ic]) { + const x = position.getX(vertexIndex) + const y = position.getY(vertexIndex) + if (y >= segment.wallHeight - 0.001) { + sideInfillX.push(x) + } + } + } + } + } + + return { geometry, roofSideX, sideInfillNormals, sideInfillX } + } + + test('keeps standalone shed side infill inside the overhanging roof edge', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_shed', + type: 'roof-segment', + roofType: 'shed', + width: 8, + depth: 6, + wallHeight: 2.6, + wallThickness: 0.1, + pitch: 25, + overhang: 0.3, + deckThickness: 0.1, + shingleThickness: 0.05, + }) + const wallSideX = segment.width / 2 + const { geometry, roofSideX, sideInfillNormals, sideInfillX } = inspectShedGeometry(segment) + + expect(sideInfillNormals).toHaveLength(2) + expect(sideInfillX.length).toBeGreaterThan(0) + expect(sideInfillNormals.every((panelNormal) => Math.abs(panelNormal.x) > 0.95)).toBe(true) + expect(sideInfillNormals.every((panelNormal) => Math.abs(panelNormal.z) < 0.05)).toBe(true) + expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeLessThan(wallSideX - 0.05) + expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeGreaterThan(wallSideX - 0.15) + expect(Math.max(...roofSideX)).toBeGreaterThan(wallSideX + segment.overhang * 0.5) + + geometry.dispose() + }) + + test('keeps configured shed side infill on the outer side-member face', () => { + const span = 4 + const leftOverhang = 0.15 + const rightOverhang = 0.15 + const rafterWidth = 0.08 + const infillHalfWidth = span / 2 + rafterWidth / 2 + const segment = RoofSegmentNode.parse({ + id: 'rseg_custom_shed', + type: 'roof-segment', + roofType: 'shed', + width: span + leftOverhang + rightOverhang, + depth: 2.77, + wallHeight: 0, + wallThickness: 0.01, + pitch: 10, + overhang: 0, + deckThickness: 0.1, + shingleThickness: 0.025, + shedSideInfillSpan: span, + shedSideInfillMinX: -infillHalfWidth, + shedSideInfillMaxX: infillHalfWidth, + }) + const { geometry, roofSideX, sideInfillNormals, sideInfillX } = inspectShedGeometry(segment) + + expect(sideInfillNormals).toHaveLength(2) + expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeCloseTo(infillHalfWidth, 5) + expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeGreaterThan(span / 2) + expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeLessThan(span / 2 + leftOverhang) + expect(Math.max(...roofSideX)).toBeGreaterThan(span / 2 + leftOverhang * 0.5) + + geometry.dispose() + }) + + test('bends a curved shed deck into a thin concentric band (no balloon)', () => { + const depth = 2 + // Arc chosen so the back (wall) edge lands at radius 5 and the front edge + // at radius 5 - depth = 3: a thin band, never a disc. + const centerX = 0 + const centerZ = 5 - depth / 2 + const radius = 5 + const segment = RoofSegmentNode.parse({ + id: 'rseg_curved_shed', + type: 'roof-segment', + roofType: 'shed', + width: 8, + depth, + wallHeight: 0, + wallThickness: 0.01, + pitch: 10, + overhang: 0, + deckThickness: 0.1, + shingleThickness: 0.025, + arc: { centerX, centerZ, radius }, + }) + + const geometry = generateRoofSegmentGeometry(segment) + const position = geometry.getAttribute('position') + expect(position.count).toBeGreaterThan(0) + // O(N) vertices, not O(N^2): a faceted band, not a triangulated disc. + expect(position.count).toBeLessThan(1000) + + const distances: number[] = [] + for (let i = 0; i < position.count; i++) { + const dx = position.getX(i) - centerX + const dz = position.getZ(i) - centerZ + distances.push(Math.hypot(dx, dz)) + } + const minR = Math.min(...distances) + const maxR = Math.max(...distances) + + // Every vertex stays within the annulus [R - depth, R]; nothing fans out + // toward the center (the old sagitta balloon bug drove vertices to ~0). + expect(minR).toBeGreaterThan(radius - depth - 0.02) + expect(maxR).toBeLessThan(radius + 0.02) + // The band spans one depth in radius, with its outer edge at the wall. + expect(maxR).toBeCloseTo(radius, 1) + expect(minR).toBeCloseTo(radius - depth, 1) + + const distanceToEdge = (a: THREE.Vector3, b: THREE.Vector3) => { + const ax = a.x - centerX + const az = a.z - centerZ + const bx = b.x - centerX + const bz = b.z - centerZ + const dx = bx - ax + const dz = bz - az + const lengthSquared = dx * dx + dz * dz + const t = + lengthSquared > 1e-12 ? Math.max(0, Math.min(1, -(ax * dx + az * dz) / lengthSquared)) : 0 + return Math.hypot(ax + dx * t, az + dz * t) + } + const index = geometry.getIndex()! + let minimumTriangleEdgeRadius = Number.POSITIVE_INFINITY + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + for (let offset = 0; offset < index.count; offset += 3) { + a.fromBufferAttribute(position, index.getX(offset)) + b.fromBufferAttribute(position, index.getX(offset + 1)) + c.fromBufferAttribute(position, index.getX(offset + 2)) + minimumTriangleEdgeRadius = Math.min( + minimumTriangleEdgeRadius, + distanceToEdge(a, b), + distanceToEdge(b, c), + distanceToEdge(c, a), + ) + } + + // Vertex-only checks miss fan-triangulation diagonals that cut across the + // open center and visually fill the annulus as a solid sector. + expect(minimumTriangleEdgeRadius).toBeGreaterThan(radius - depth - 0.1) + + geometry.dispose() + }) + + test('keeps a reverse-radius curved shed as a sloped annular band', () => { + const depth = 2 + const centerX = 0 + const centerZ = -6 + const highRadius = 5 + const lowRadius = 7 + const segment = RoofSegmentNode.parse({ + id: 'rseg_curved_shed_outer', + type: 'roof-segment', + roofType: 'shed', + width: 8, + depth, + wallHeight: 0, + wallThickness: 0.01, + pitch: 10, + overhang: 0, + deckThickness: 0.1, + shingleThickness: 0.025, + arc: { centerX, centerZ, radius: highRadius }, + }) + + const geometry = generateRoofSegmentGeometry(segment) + const position = geometry.getAttribute('position') + const index = geometry.getIndex()! + let minRadius = Number.POSITIVE_INFINITY + let maxRadius = Number.NEGATIVE_INFINITY + const topHighYs: number[] = [] + const topLowYs: number[] = [] + + for (let vertex = 0; vertex < position.count; vertex++) { + const radius = Math.hypot(position.getX(vertex) - centerX, position.getZ(vertex) - centerZ) + minRadius = Math.min(minRadius, radius) + maxRadius = Math.max(maxRadius, radius) + } + for (const group of geometry.groups) { + if (group.materialIndex !== 3) continue + for (let offset = group.start; offset < group.start + group.count; offset++) { + const vertex = index.getX(offset) + const radius = Math.hypot(position.getX(vertex) - centerX, position.getZ(vertex) - centerZ) + if (Math.abs(radius - highRadius) < 0.05) topHighYs.push(position.getY(vertex)) + if (Math.abs(radius - lowRadius) < 0.05) topLowYs.push(position.getY(vertex)) + } + } + + expect(minRadius).toBeCloseTo(highRadius, 1) + expect(maxRadius).toBeCloseTo(lowRadius, 1) + expect(topHighYs.length).toBeGreaterThan(0) + expect(topLowYs.length).toBeGreaterThan(0) + expect(Math.min(...topHighYs)).toBeGreaterThan(Math.max(...topLowYs)) + + geometry.dispose() + }) +}) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 850147107f..affd5c07b5 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -5,10 +5,12 @@ import { getDutchRoofShapeMetrics, getEffectiveNode, getRoofModuleFaces, + getRoofSegmentSurfaceY, getRoofShapeInsets, getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, + isBandedShedSegment, nodeRegistry, normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, @@ -509,8 +511,31 @@ function updateMergedRoofGeometry( let totalWall: Brush | null = null let totalInner: Brush | null = null const rakeBoardGeometries: THREE.BufferGeometry[] = [] + const directSegmentGeometries: THREE.BufferGeometry[] = [] + const csgChildren: RoofSegmentNode[] = [] for (const child of children) { + const directGeometry = withSegmentUvMatrix( + composeSegmentWorldMatrix( + roofNode.position, + roofNode.rotation ?? 0, + child.position, + child.rotation ?? 0, + ), + () => buildCustomShedGeometry(child), + ) + if (directGeometry) { + const withPanels = addShedInsetEndPanels(directGeometry, [child], false) + _matrix.compose( + _position.set(child.position[0], child.position[1], child.position[2]), + _quaternion.setFromAxisAngle(_yAxis, child.rotation), + _scale, + ) + withPanels.applyMatrix4(_matrix) + directSegmentGeometries.push(withPanels) + continue + } + csgChildren.push(child) const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes) if (!brushes) continue if (brushes.rakeBoards) { @@ -537,35 +562,43 @@ function updateMergedRoofGeometry( totalDeckSlab = brushes.deckSlab } - if (totalWall) { - const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush - totalWall.geometry.dispose() + if (child.roofType === 'shed') { brushes.wallBrush.geometry.dispose() - prepareBrushForCSG(next) - totalWall = next - } else { - totalWall = brushes.wallBrush - } - - if (totalInner) { - const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush - totalInner.geometry.dispose() brushes.innerBrush.geometry.dispose() - prepareBrushForCSG(next) - totalInner = next } else { - totalInner = brushes.innerBrush + if (totalWall) { + const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush + totalWall.geometry.dispose() + brushes.wallBrush.geometry.dispose() + prepareBrushForCSG(next) + totalWall = next + } else { + totalWall = brushes.wallBrush + } + + if (totalInner) { + const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush + totalInner.geometry.dispose() + brushes.innerBrush.geometry.dispose() + prepareBrushForCSG(next) + totalInner = next + } else { + totalInner = brushes.innerBrush + } } } - if (totalShinSlab && totalDeckSlab && totalWall && totalInner) { + if (totalShinSlab && totalDeckSlab) { try { - const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) - prepareBrushForCSG(finalWallTrimmed) - const shinDeck = csgEvaluator.evaluate(totalShinSlab, totalDeckSlab, ADDITION) prepareBrushForCSG(shinDeck) - const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) + let combined = shinDeck + let finalWallTrimmed: Brush | null = null + if (totalWall && totalInner) { + finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) + prepareBrushForCSG(finalWallTrimmed) + combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) + } prepareBrushForCSG(combined) const resultGeo = csgGeometry(combined) @@ -578,13 +611,14 @@ function updateMergedRoofGeometry( warnedMergedRoofNaNIds.add(roofNode.id) } resultGeo.dispose() - finalWallTrimmed.geometry.dispose() - shinDeck.geometry.dispose() + finalWallTrimmed?.geometry.dispose() + if (combined !== shinDeck) shinDeck.geometry.dispose() totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() - totalWall.geometry.dispose() - totalInner.geometry.dispose() + totalWall?.geometry.dispose() + totalInner?.geometry.dispose() for (const geometry of rakeBoardGeometries) geometry.dispose() + for (const geometry of directSegmentGeometries) geometry.dispose() return } @@ -601,33 +635,52 @@ function updateMergedRoofGeometry( g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) } - let finalGeo = resultGeo - if (rakeBoardGeometries.length > 0) { - const merged = mergeGeometriesPreservingGroups([finalGeo, ...rakeBoardGeometries]) + let finalGeo = addShedInsetEndPanels(resultGeo, csgChildren, true) + const appendedGeometries = [...rakeBoardGeometries, ...directSegmentGeometries] + if (appendedGeometries.length > 0) { + const merged = mergeGeometriesPreservingGroups([finalGeo, ...appendedGeometries]) if (merged) { finalGeo.dispose() finalGeo = merged } } for (const geometry of rakeBoardGeometries) geometry.dispose() + for (const geometry of directSegmentGeometries) geometry.dispose() + directSegmentGeometries.length = 0 finalGeo.computeVertexNormals() ensureRenderableGeometryAttributes(finalGeo) mergedMesh.geometry.dispose() mergedMesh.geometry = finalGeo - finalWallTrimmed.geometry.dispose() - shinDeck.geometry.dispose() + finalWallTrimmed?.geometry.dispose() + if (combined !== shinDeck) shinDeck.geometry.dispose() } catch (e) { console.error('Merged roof CSG failed:', e) } totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() - totalWall.geometry.dispose() - totalInner.geometry.dispose() + totalWall?.geometry.dispose() + totalInner?.geometry.dispose() for (const geometry of rakeBoardGeometries) geometry.dispose() } + + if (directSegmentGeometries.length > 0) { + const finalGeo = + directSegmentGeometries.length === 1 + ? directSegmentGeometries[0]! + : mergeGeometriesPreservingGroups(directSegmentGeometries) + if (finalGeo) { + finalGeo.computeVertexNormals() + ensureRenderableGeometryAttributes(finalGeo) + mergedMesh.geometry.dispose() + mergedMesh.geometry = finalGeo + } + for (const geometry of directSegmentGeometries) { + if (geometry !== finalGeo) geometry.dispose() + } + } } function geometryHasInvalidAttributes(geometry: THREE.BufferGeometry) { @@ -875,6 +928,8 @@ 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 ROOF_EDGE_MATERIAL_INDEX = 0 +const ROOF_INSET_WALL_MATERIAL_INDEX = 2 const DUTCH_RAKE_SIDE_MATERIAL_INDEX = 1 const DUTCH_RAKE_TOP_MATERIAL_INDEX = 3 const DUTCH_RAKE_SLOPE_SEAT_OFFSET = 0.0002 @@ -884,6 +939,30 @@ function pushDoubleSidedFace(targetFaces: THREE.Vector3[][], face: THREE.Vector3 targetFaces.push(face.map((point) => point.clone()).reverse()) } +type ShedEndSide = 'left' | 'right' +type RoofPlanPolygon = [number, number][] + +function readShedFootprintPieces(node: RoofSegmentNode): RoofPlanPolygon[] { + const value = node.shedFootprintPieces + if (!Array.isArray(value)) return [] + return value.flatMap((polygon) => { + if (!Array.isArray(polygon)) return [] + const points = polygon.flatMap((point): [number, number][] => { + if (!Array.isArray(point) || point.length < 2) return [] + const x = readFiniteNumber(point[0]) + const z = readFiniteNumber(point[1]) + return x === null || z === null ? [] : [[x, z]] + }) + return points.length >= 3 && points.length === polygon.length ? [points] : [] + }) +} + +function readShedOpenEndSides(node: RoofSegmentNode): Set { + const value = node.shedOpenEndSides + if (!Array.isArray(value)) return new Set() + return new Set(value.filter((side): side is ShedEndSide => side === 'left' || side === 'right')) +} + function hasSegmentTrim(node: RoofSegmentNode): boolean { const trim = normalizeRoofSegmentTrim(node) return ( @@ -916,7 +995,6 @@ function hasSegmentTrim(node: RoofSegmentNode): boolean { // slots. Accessories still clamp the slot via `useSegmentTrimClippedGeometry` // when they expose fewer material slots. const TRIM_CUT_MATERIAL_SLOT = 0 - function assignTrimCutterSlot(geometry: THREE.BufferGeometry): void { geometry.clearGroups() const count = geometry.index ? geometry.index.count : geometry.getAttribute('position').count @@ -1208,6 +1286,7 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe baseY: number, matIndex: number, isVoid: boolean, + materialRule?: (normal: THREE.Vector3) => number, ) => { const wV = Math.max(0.01, width + 2 * wExt) const dV = Math.max(0.01, depth + 2 * wExt) @@ -1242,7 +1321,7 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe shapeRatios, dutchTopRakeThickness: node.dutchTopRakeThickness, }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) - return createGeometryFromFaces(faces, matIndex) + return createGeometryFromFaces(faces, materialRule ?? matIndex) } const wallGeo = getVol(wallThickness / 2, 0, 0, 0, false) @@ -1251,7 +1330,12 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe const horizontalOverhang = overhang * cosTheta const deckExt = wallThickness / 2 + horizontalOverhang - const deckTopGeo = getVol(deckExt, verticalRt, 0, 1, false) + const shedRoofSideMaterialRule = + roofType === 'shed' + ? (normal: THREE.Vector3) => + normal.y > SHINGLE_SURFACE_EPSILON ? 3 : ROOF_EDGE_MATERIAL_INDEX + : undefined + const deckTopGeo = getVol(deckExt, verticalRt, 0, 1, false, shedRoofSideMaterialRule) const deckBotGeo = getVol(deckExt, 0, -5, 0, true) const stSin = shingleThickness * sinTheta @@ -1368,11 +1452,12 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe ) } + const shedRoofSideMaterialIndex = roofType === 'shed' ? ROOF_EDGE_MATERIAL_INDEX : 1 const shinBotGeo = createGeometryFromFaces(botFaces, (normal) => - normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, + normal.y > SHINGLE_SURFACE_EPSILON ? 3 : shedRoofSideMaterialIndex, ) const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => - normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, + normal.y > SHINGLE_SURFACE_EPSILON ? 3 : shedRoofSideMaterialIndex, ) if (transZ !== 0) { @@ -1490,15 +1575,22 @@ export function generateRoofSegmentGeometry( parentRoof && 'rotation' in parentRoof ? ((parentRoof as { rotation?: number }).rotation ?? 0) : 0 - const brushes = withSegmentUvMatrix( - composeSegmentWorldMatrix( - parentRoofPosition, - parentRoofRotation, - node.position, - node.rotation ?? 0, - ), - () => getRoofSegmentBrushes(node), + const segmentWorldMatrix = composeSegmentWorldMatrix( + parentRoofPosition, + parentRoofRotation, + node.position, + node.rotation ?? 0, + ) + const directShedGeometry = withSegmentUvMatrix(segmentWorldMatrix, () => + buildCustomShedGeometry(node), ) + if (directShedGeometry) { + const result = addShedInsetEndPanels(directShedGeometry, [node], false) + result.computeVertexNormals() + ensureRenderableGeometryAttributes(result) + return result + } + const brushes = withSegmentUvMatrix(segmentWorldMatrix, () => getRoofSegmentBrushes(node)) if (!brushes) { // Fallback: simple box return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth) @@ -1512,11 +1604,15 @@ export function generateRoofSegmentGeometry( let resultGeo = new THREE.BufferGeometry() try { - const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION) - prepareBrushForCSG(hollowWall) const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION) prepareBrushForCSG(shinDeck) - const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) + let combined = shinDeck + let hollowWall: Brush | null = null + if (node.roofType !== 'shed') { + hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION) + prepareBrushForCSG(hollowWall) + combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) + } prepareBrushForCSG(combined) resultGeo = csgGeometry(combined) @@ -1543,9 +1639,10 @@ export function generateRoofSegmentGeometry( } remapRoofShellFaces(resultGeo, node) + resultGeo = addShedInsetEndPanels(resultGeo, [node], false) - hollowWall.geometry.dispose() - shinDeck.geometry.dispose() + hollowWall?.geometry.dispose() + if (combined !== shinDeck) shinDeck.geometry.dispose() } catch (e) { console.error('Roof CSG failed:', e) resultGeo = csgGeometry(wallBrush).clone() @@ -1635,6 +1732,227 @@ function mergeGeometriesPreservingGroups( return merged } +// Signed bend reference for a banded segment: the divisor that turns a flat +// along-width coordinate into an arc angle. +function bandSignedRef(arc: NonNullable): number { + return (Math.sign(arc.centerZ) || 1) * arc.radius +} + +// Concentric map: rotate a flat segment-local (x, z) about the stored arc center by +// the angle its along-width coordinate subtends. The back (wall) edge lands at the +// wall's radius, the front edge at radius ± depth — a thin annular band, never a disc. +function bendBandPoint( + arc: NonNullable, + signedRef: number, + x: number, + z: number, +): { x: number; z: number } { + const phi = (x - arc.centerX) / signedRef + const radial = z - arc.centerZ + return { + x: arc.centerX - radial * Math.sin(phi), + z: arc.centerZ + radial * Math.cos(phi), + } +} + +// Remap every vertex of a flat segment-local geometry onto the concentric band, +// keeping Y. Used for the side-infill end panels so they follow the arc ends. +function applyBandBendToGeometry( + geometry: THREE.BufferGeometry, + arc: NonNullable, +): void { + const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined + if (!position) return + const signedRef = bandSignedRef(arc) + for (let index = 0; index < position.count; index++) { + const bent = bendBandPoint(arc, signedRef, position.getX(index), position.getZ(index)) + position.setX(index, bent.x) + position.setZ(index, bent.z) + } + position.needsUpdate = true +} + +// Faceted annular-band deck for a shed segment bent across its width. The slope runs +// unchanged along depth (Z); the width axis (X) sweeps the stored concentric arc, so +// the deck hugs the host wall as a thin band and can never balloon into a disc. +function buildConcentricBandDeckGeometry(node: RoofSegmentNode): THREE.BufferGeometry | null { + const arc = node.arc + if (!arc) return null + const width = node.width + const halfWidth = width / 2 + const halfDepth = node.depth / 2 + const signedRef = bandSignedRef(arc) + const { cosTheta } = getSegmentSlopeFrame(node) + const verticalThickness = + node.deckThickness / Math.max(0.1, cosTheta) + node.shingleThickness * cosTheta + const facetCount = Math.max(4, Math.min(32, Math.ceil(width / 0.4))) + + const bend = (localX: number, localZ: number) => { + const bent = bendBandPoint(arc, signedRef, localX, localZ) + return new THREE.Vector3(bent.x, getRoofSegmentSurfaceY(node, localX, localZ), bent.z) + } + + const backBottom: THREE.Vector3[] = [] + const frontBottom: THREE.Vector3[] = [] + for (let index = 0; index <= facetCount; index++) { + const localX = -halfWidth + (index / facetCount) * width + backBottom.push(bend(localX, -halfDepth)) + frontBottom.push(bend(localX, halfDepth)) + } + + const raise = (point: THREE.Vector3) => + new THREE.Vector3(point.x, point.y + verticalThickness, point.z) + const backTop = backBottom.map(raise) + const frontTop = frontBottom.map(raise) + const faces: THREE.Vector3[][] = [] + + for (let index = 0; index < facetCount; index++) { + const next = index + 1 + // Each angular interval is its own convex quad. A single polygon around + // the complete annular boundary is concave, so fan triangulation sends + // diagonals through the open center and fills the roof as a solid sector. + faces.push( + [ + backBottom[index]!.clone(), + backBottom[next]!.clone(), + frontBottom[next]!.clone(), + frontBottom[index]!.clone(), + ], + [ + frontTop[index]!.clone(), + frontTop[next]!.clone(), + backTop[next]!.clone(), + backTop[index]!.clone(), + ], + [ + backBottom[next]!.clone(), + backBottom[index]!.clone(), + backTop[index]!.clone(), + backTop[next]!.clone(), + ], + [ + frontBottom[index]!.clone(), + frontBottom[next]!.clone(), + frontTop[next]!.clone(), + frontTop[index]!.clone(), + ], + ) + } + + const last = facetCount + faces.push( + [backBottom[0]!.clone(), frontBottom[0]!.clone(), frontTop[0]!.clone(), backTop[0]!.clone()], + [ + frontBottom[last]!.clone(), + backBottom[last]!.clone(), + backTop[last]!.clone(), + frontTop[last]!.clone(), + ], + ) + const merged = createGeometryFromFaces(faces, (normal) => + normal.y > SHINGLE_SURFACE_EPSILON ? 3 : ROOF_EDGE_MATERIAL_INDEX, + ) + merged.computeVertexNormals() + ensureRenderableGeometryAttributes(merged) + return merged +} + +function clipRoofPolygonAtX( + polygon: readonly [number, number][], + boundaryX: number, + keepGreater: boolean, +): RoofPlanPolygon { + const clipped: RoofPlanPolygon = [] + for (let index = 0; index < polygon.length; index++) { + const current = polygon[index]! + const next = polygon[(index + 1) % polygon.length]! + const currentInside = keepGreater ? current[0] >= boundaryX : current[0] <= boundaryX + const nextInside = keepGreater ? next[0] >= boundaryX : next[0] <= boundaryX + if (currentInside) clipped.push([current[0], current[1]]) + if (currentInside === nextInside) continue + const ratio = (boundaryX - current[0]) / (next[0] - current[0]) + clipped.push([boundaryX, current[1] + (next[1] - current[1]) * ratio]) + } + return clipped +} + +function facetBandedRoofPieces( + pieces: readonly RoofPlanPolygon[], + width: number, +): RoofPlanPolygon[] { + const facetCount = Math.max(4, Math.min(32, Math.ceil(width / 0.4))) + const halfWidth = width / 2 + const facetWidth = width / facetCount + const faceted: RoofPlanPolygon[] = [] + for (const piece of pieces) { + for (let index = 0; index < facetCount; index++) { + const minX = -halfWidth + index * facetWidth + const maxX = index === facetCount - 1 ? halfWidth : minX + facetWidth + const clipped = clipRoofPolygonAtX(clipRoofPolygonAtX(piece, minX, true), maxX, false) + const area = clipped.reduce((sum, point, pointIndex) => { + const next = clipped[(pointIndex + 1) % clipped.length]! + return sum + point[0] * next[1] - next[0] * point[1] + }, 0) + if (clipped.length >= 3 && Math.abs(area) > 1e-8) faceted.push(clipped) + } + } + return faceted +} + +function buildCustomShedGeometry(node: RoofSegmentNode): THREE.BufferGeometry | null { + if (node.roofType !== 'shed') return null + const pieces = readShedFootprintPieces(node) + const banded = isBandedShedSegment(node) && node.arc + if (pieces.length === 0) return banded ? buildConcentricBandDeckGeometry(node) : null + const renderPieces = banded + ? [...facetBandedRoofPieces(pieces.slice(0, 1), node.width), ...pieces.slice(1)] + : pieces + + const { cosTheta } = getSegmentSlopeFrame(node) + const verticalThickness = + node.deckThickness / Math.max(0.1, cosTheta) + node.shingleThickness * cosTheta + const geometries: THREE.BufferGeometry[] = [] + + for (const polygon of renderPieces) { + const signedArea = polygon.reduce((area, point, index) => { + const next = polygon[(index + 1) % polygon.length]! + return area + point[0] * next[1] - next[0] * point[1] + }, 0) + if (Math.abs(signedArea) <= 1e-9) continue + const outline = signedArea > 0 ? polygon : [...polygon].reverse() + const bottom = outline.map( + ([x, z]) => new THREE.Vector3(x, getRoofSegmentSurfaceY(node, x, z), z), + ) + const top = [...bottom] + .reverse() + .map((point) => new THREE.Vector3(point.x, point.y + verticalThickness, point.z)) + const faces: THREE.Vector3[][] = [bottom, top] + for (let index = 0; index < bottom.length; index++) { + const next = (index + 1) % bottom.length + faces.push([ + bottom[next]!.clone(), + bottom[index]!.clone(), + new THREE.Vector3(bottom[index]!.x, bottom[index]!.y + verticalThickness, bottom[index]!.z), + new THREE.Vector3(bottom[next]!.x, bottom[next]!.y + verticalThickness, bottom[next]!.z), + ]) + } + geometries.push( + createGeometryFromFaces(faces, (normal) => + normal.y > SHINGLE_SURFACE_EPSILON ? 3 : ROOF_EDGE_MATERIAL_INDEX, + ), + ) + } + + if (geometries.length === 0) return null + const merged = mergeGeometriesPreservingGroups(geometries) + for (const geometry of geometries) geometry.dispose() + if (!merged) return null + if (banded) applyBandBendToGeometry(merged, banded) + merged.computeVertexNormals() + ensureRenderableGeometryAttributes(merged) + 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 @@ -1775,7 +2093,6 @@ export function remapRoofShellFaces(geometry: THREE.BufferGeometry, node: RoofSe for (let triangleIndex = startTriangle; triangleIndex < endTriangle; triangleIndex++) { const indexOffset = triangleIndex * 3 let materialIndex = normalizeRoofMaterialIndex(group.materialIndex) - if (materialIndex === 1 || materialIndex === 3) { const ia = index.getX(indexOffset) const ib = index.getX(indexOffset + 1) @@ -2226,6 +2543,130 @@ function buildDutchRakeBoards( return merged } +function createShedInsetEndPanelGeometry(node: RoofSegmentNode): THREE.BufferGeometry | null { + if (node.roofType !== 'shed') return null + + const trim = normalizeRoofSegmentTrim(node) + const openEndSides = readShedOpenEndSides(node) + const hasCornerSide = (side: -1 | 1) => + side < 0 + ? openEndSides.has('left') || + (trim.frontLeftX > 0 && trim.frontLeftZ > 0) || + (trim.backLeftX > 0 && trim.backLeftZ > 0) + : (trim.frontRightX > 0 && trim.frontRightZ > 0) || + (trim.backRightX > 0 && trim.backRightZ > 0) || + openEndSides.has('right') + const sideInset = Math.min(Math.max(node.wallThickness, 0.05), node.overhang * 0.5, 0.12) + const fallbackPanelHalfWidth = Math.max(0.01, node.width / 2 - sideInset) + const sidePanelX = (side: -1 | 1) => resolveShedSideInfillX(node, side, fallbackPanelHalfWidth) + const { activeRh, tanTheta } = getSegmentSlopeFrame(node) + const shapeRatios = getRoofShapeRatios({ + gambrelLowerWidthRatio: node.gambrelLowerWidthRatio, + mansardSteepWidthRatio: node.mansardSteepWidthRatio, + dutchHipWidthRatio: node.dutchHipWidthRatio, + dutchHipHeightRatio: node.dutchHipHeightRatio, + dutchWaistLengthRatio: node.dutchWaistLengthRatio, + dutchGabletRake: node.dutchGabletRake, + }) + const wallOuterOffset = node.wallThickness / 2 + const autoDrop = wallOuterOffset * tanTheta + const wh = Math.max(0.01, node.wallHeight - autoDrop) + const rh = activeRh > 0 ? activeRh + 2 * autoDrop : activeRh + + const faces = getRoofModuleFaces({ + type: 'shed', + w: node.width + node.wallThickness, + d: node.depth + node.wallThickness, + wh, + rh, + baseY: 0, + insets: {}, + baseW: node.width, + baseD: node.depth, + tanTheta, + shapeRatios, + dutchTopRakeThickness: node.dutchTopRakeThickness, + }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) + + const wallFaces: THREE.Vector3[][] = [] + for (const faceIndex of [6, 8]) { + const face = faces[faceIndex] + if (!face) continue + const faceSide = face.some((point) => point.x < 0) ? -1 : 1 + if (hasCornerSide(faceSide)) continue + wallFaces.push( + face.map((point) => { + const side = point.x < 0 ? -1 : 1 + return new THREE.Vector3(sidePanelX(side), point.y, point.z) + }), + ) + } + + if (wallFaces.length === 0) return null + return createGeometryFromFaces(wallFaces, ROOF_INSET_WALL_MATERIAL_INDEX) +} + +function resolveShedSideInfillX( + node: RoofSegmentNode, + side: -1 | 1, + fallbackPanelHalfWidth: number, +): number { + const sideX = readFiniteNumber(side < 0 ? node.shedSideInfillMinX : node.shedSideInfillMaxX) + if (sideX !== null) return THREE.MathUtils.clamp(sideX, -node.width / 2, node.width / 2) + + const span = readFiniteNumber(node.shedSideInfillSpan) + if (span !== null && span > 0) { + return side * Math.min(span / 2, node.width / 2) + } + + return side * fallbackPanelHalfWidth +} + +function readFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function addShedInsetEndPanels( + geometry: THREE.BufferGeometry, + segments: readonly RoofSegmentNode[], + applySegmentTransform: boolean, +): THREE.BufferGeometry { + const shedSegments = segments.filter((segment) => segment.roofType === 'shed') + if (shedSegments.length === 0) return geometry + + const panelGeometries: THREE.BufferGeometry[] = [] + + for (const segment of shedSegments) { + const panel = createShedInsetEndPanelGeometry(segment) + if (!panel) continue + + // A banded (curved) deck rotates each span end about the arc center; the flat + // end panel is at a fixed X, so the bend is a rigid rotation that seats it on + // the arc end. Applied before any segment transform so it stays segment-local. + if (isBandedShedSegment(segment) && segment.arc) applyBandBendToGeometry(panel, segment.arc) + + if (applySegmentTransform) { + _matrix.compose( + _position.set(segment.position[0], segment.position[1], segment.position[2]), + _quaternion.setFromAxisAngle(_yAxis, segment.rotation), + _scale, + ) + panel.applyMatrix4(_matrix) + } + + panelGeometries.push(panel) + } + + if (panelGeometries.length === 0) return geometry + + const merged = mergeGeometriesPreservingGroups([geometry, ...panelGeometries]) + for (const panel of panelGeometries) panel.dispose() + if (!merged) return geometry + + geometry.dispose() + return merged +} + /** * Converts an array of face polygons into a BufferGeometry. * Each face is triangulated via fan triangulation. diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 5269d612c4..96bc92827b 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -1,10 +1,10 @@ # Tools -*Editor tools structure in `apps/editor`.* +*Editor tools and registry-owned placement interactions.* -Applies to: `apps/editor/components/tools/**`. +Applies to: `apps/editor/components/tools/**` and `packages/nodes/src/*/{tool,floorplan-tool}.tsx`. -Tools are React components that capture user input (pointer, keyboard) and translate it into `useScene` mutations. They live exclusively in `apps/editor/components/tools/`. +Tools are React components that capture user input (pointer, keyboard) and translate it into `useScene` mutations. Cross-kind and application-level tools live in `apps/editor/components/tools/`. A registry-owned node kind may colocate its 3D `def.tool` and floorplan tool extension in `packages/nodes/src//`; this keeps the complete kind registration removable and discoverable as one unit. These components may consume the public editor interaction APIs, but must not add app-specific state or import from `apps/editor`. ## Lifecycle diff --git a/wiki/lean-to-post-spacing-research.md b/wiki/lean-to-post-spacing-research.md new file mode 100644 index 0000000000..63a71a368c --- /dev/null +++ b/wiki/lean-to-post-spacing-research.md @@ -0,0 +1,24 @@ +# Lean-to post spacing research + +This note records the basis for Pascal's automatic lean-to post layout default. + +## Findings + +- Municipal patio-cover guides treat post spacing as a beam/span design input, not a single universal code value. +- City of La Habra's standard open patio-cover guide includes header tables across post spacings from 6 ft to 20 ft. +- The same La Habra guide warns that rafters spanning more than 8 ft may permanently deflect unless larger lumber is used. +- City of San Diego's patio-cover bulletin defines patio covers as open, one-story accessory structures and ties post sizing to height, while directing custom designs to show framing/foundation details and structural calculations. + +## Product default + +Use 3.0 m, approximately 10 ft, as the automatic target post spacing for generated lean-to extensions. + +This is a visual/planning default, not a structural-code guarantee. It keeps generated spans in the common municipal table range, avoids the clutter of very close spacing, and avoids the heavier-beam implication of wider spacing above roughly 12 ft. + +Automatic generation should always include end posts. Intermediate posts are inserted so no bay is larger than the target spacing. + +## Sources + +- City of La Habra, `Standard Open Patio Cover Requirements`: post-spacing tables include 6 ft, 8 ft, 10 ft, and larger spacings; the guide also warns about objectionable deflection beyond 8 ft rafter spans. https://www.lahabraca.gov/DocumentCenter/View/91/Standard-Open-Patio-Cover-PDF +- City of San Diego, Information Bulletin 206 `Patio Covers`, March 2026: patio covers are open accessory structures, and plans must show framing/foundation details when using custom designs. https://www.sandiego.gov/development-services/forms-publications/information-bulletins/206 +- City of Escondido, `Solid Roof Patio Cover`: beam and post spacing are design-table variables dependent on roof span, roof load, lumber, and footing assumptions. https://www.escondido.gov/DocumentCenter/View/457/8B---Solid-Roof-Patio-Cover-PDF diff --git a/wiki/shed-roof-extension-research.md b/wiki/shed-roof-extension-research.md new file mode 100644 index 0000000000..19426fbb99 --- /dev/null +++ b/wiki/shed-roof-extension-research.md @@ -0,0 +1,128 @@ +# Shed / Lean-to Roof Extension Research + +## Scope + +The reference images show an **attached, open-sided lean-to canopy** rather than a new main-roof shape: one roof plane has a high edge at an existing building and a low edge carried by a beam and posts. The examples vary mainly in span and context: + +- a long veranda across a facade; +- a courtyard canopy terminating against a second building; and +- a small entrance canopy with two front posts. + +This distinction matters in Pascal because `RoofSegmentNode` already has a `roofType: 'shed'`. The proposed feature adds attachment, supports, framing, and drainage to that one-plane geometry. + +## Terminology + +- **Shed roof** means a roof with one sloping plane in the [California State University Channel Islands master-plan glossary](https://www.csuci.edu/fs/pdc/documents/csuci2007masterplan.pdf). +- **Mono-pitched roof** is the corresponding UK term: the [City of Edinburgh Council glossary](https://www.edinburgh.gov.uk/housing/improving-edinburgh-neighbourhoods/7) defines it as a roof with one sloping side, usually attached to a wall. [Bury Council](https://www.bury.gov.uk/housing/housing-services/your-home/repairs/alterations-to-your-property/terms-and-conditions) notes that a mono-pitched roof is often called a **lean-to**. +- **Skillion roof** is the common Australian term for the same single-plane form; the [Australian Government's YourHome glossary](https://www.yourhome.gov.au/glossary) lists shed-style and lean-to as aliases. +- **Rafters** are the sloping members carrying a pitched roof; **purlins** run horizontally and support rafters, according to the same [City of Edinburgh glossary](https://www.edinburgh.gov.uk/housing/improving-edinburgh-neighbourhoods/7). Actual light-metal canopy systems may instead place panels across regularly spaced supports, so the editor should treat framing layout as a strategy rather than assume that every assembly contains both rafters and purlins. + +For the product UI, **Lean-to extension** or **Attached canopy** is clearer than just **Shed roof**. It avoids confusion with both a storage shed and Pascal's existing standalone shed-shaped roof segment. + +## Typical assembly and load path + +A conventional attached wood canopy can be represented as this hierarchy: + +1. Roof covering and optional sheathing/deck. +2. Repeated sloping rafters, or a covering-specific support layout. +3. A high-side support at the building: normally a structurally fixed wall ledger, or a separate high beam and posts for a freestanding/independent canopy. +4. A low-side beam at the eave. +5. Repeated posts/columns, with top bracing where required. +6. Post bases and footings carrying loads to the ground. +7. Flashing at building abutments and a gutter/downspout at the low eave. + +The [City of San Diego patio-cover bulletin](https://www.sandiego.gov/development-services/forms-publications/information-bulletins/206) is a useful official example of this system. It requires posts to be anchored at the bottom and braced at the top; describes replacing the building-side beam with a ledger attached to wall studs; says patio rafters must not be supported solely by existing rafter tails or fascia; and warns that existing headers beside openings may need verification. Its prescriptive sizes and fastener schedules are local design rules, not universal Pascal defaults. + +The first-party [Stratco Outback Skillion installation guide](https://www.stratco.com.au/siteassets/pdfs/stratco-outback-skillion-installation-guide15-10-20.pdf) shows the same assembly in a proprietary metal system: columns, low beam, rafters, purlins, high-side back channel, cladding, barge flashing, gutter, and downpipe. It also illustrates purlins placed either between or above rafters and roof sheets turned up at the high end and down toward the gutter. Its 5-degree fall and component dimensions are product-specific examples, not general construction defaults. + +The same bulletin requires custom designs to include framing and foundation plans, sections, connection details, and structural calculations. Pascal should therefore model the geometry and assembly accurately but should not label arbitrary member sizes or spans as structurally compliant unless a jurisdiction/load profile and verified calculation engine are added. + +## Weathering and drainage + +- The high-side wall intersection needs a modeled flashing/abutment condition. [IRC 2015 R903.2.1](https://codes.iccsafe.org/s/IRC2015/chapter-9-roof-assemblies/IRC2015-Pt03-Ch09-SecR903.2.1) requires flashing at wall/roof intersections, roof slope or direction changes, and roof openings, illustrating that this is part of the assembly rather than decorative trim. +- Water should run away from the high-side attachment toward the low eave. The [San Diego bulletin](https://www.sandiego.gov/development-services/forms-publications/information-bulletins/206) uses a minimum slope of 1/4 inch in 12 inches for the patio covers in its scope. +- Minimum slope depends on the selected covering/system. For example, manufacturer specifications list a 2-degree minimum for [LYSAGHT TRIMDEK](https://lysaght.com/profiles/trimdek) and product-dependent 1- or 2-degree minima for [LYSAGHT KLIP-LOK](https://lysaght.com/profiles/klip-lok). The editor should not encode one global minimum as a universal construction rule. +- Gutters belong on the low eave. A side that terminates at another building, as in the courtyard image, also needs a sidewall/end-abutment condition rather than allowing the roof edge to pass through the wall. + +## Current Pascal capabilities and missing semantics + +- [`RoofSegmentNode`](../packages/core/src/schema/nodes/roof-segment.ts) already supports `shed`, footprint width/depth, pitch, wall height, deck and covering thickness, overhang, trim, materials, and hosted roof accessories. Its current shed geometry slopes from local `-Z` (high) to `+Z` (low). +- [`RoofNode`](../packages/core/src/schema/nodes/roof.ts) already groups multiple roof segments and provides roof-level surface materials. +- [`ColumnNode`](../packages/core/src/schema/nodes/column.ts) already provides reusable post/pillar geometry, dimensions, materials, and several braced support styles. +- [`GutterNode`](../packages/core/src/schema/nodes/gutter.ts) already attaches to a roof segment eave and supports outlets that can connect to downspouts. + +What is missing is the semantic relationship that makes these parts one editable extension: a high-side host/attachment, a low-side beam, a governed row of columns, optional exposed framing, flashing, derived elevations, and collision/clearance rules. A wall-less shed segment plus independently placed columns can approximate the pictures visually, but it will drift apart when resized or moved. + +## Implementation shapes to consider + +### 1. Manual composition from existing nodes + +Create a wall-less `shed` roof segment, then place columns and a gutter separately. + +- **Strengths:** smallest implementation and useful as a geometry proof. +- **Limitations:** no ledger/beam/flashing, no shared selection or lifecycle, and resizing the roof does not reliably update posts or drainage. +- **Use:** prototype or short-lived MVP, not the durable model. + +### 2. New composite `lean-to-extension` node (recommended) + +Store the design intent once and derive/render the roof plane, ledger or high beam, low beam, repeated supports, flashing, and optional framing. Reuse existing column and gutter behavior through owned children or well-defined references where independent editing is valuable. + +- **Strengths:** one placement flow, coherent resize/move behavior, works against buildings with any main roof type, and gives room for multiple support/attachment strategies. +- **Tradeoff:** requires a new schema/definition/renderer/system and explicit ownership rules. + +The host should normally be a **wall face or facade interval below the eave**, not the main roof type. Gable, hip, gambrel, mansard, flat, and shed roofs can all accept the same lean-to if their wall/eave geometry provides clearance. Direct attachment to an existing roof plane is a different and more complex join and should be a later explicit attachment mode. + +### 3. Extension fields on every roof segment + +Add post/beam/ledger fields directly to `RoofSegmentNode` and activate them when desired. + +- **Strengths:** reuses the current roof editing surface directly. +- **Limitations:** mixes a main-roof shape with an accessory assembly, leaves many fields inert for ordinary roofs, and makes attachment/ownership harder to express. +- **Use:** only if product semantics intentionally treat every roof segment as a potential complete canopy assembly. + +## Parameters a configurable editor should expose + +### Essential geometry + +- Host and placement: `hostWallId` or facade reference, along-wall offset, span/width, outward projection, and left/right end conditions. +- Vertical geometry: high attachment elevation plus either pitch or low-eave elevation. The third value is derived: `lowEave = highEdge - projection * tan(pitch)`. +- Dependency lock when editing: preserve **high edge**, preserve **low edge**, or preserve **pitch**. This prevents ambiguous resize behavior. +- Plane orientation: downhill direction, local rotation where detached, and alignment/clearance below the host eave. +- Overhangs: low-eave, high-side, and both end overhangs independently; one scalar overhang is insufficient at wall abutments. +- Roof build-up and appearance: deck/panel thickness, covering/material, fascia/edge material, underside/soffit material. + +### Attachment and supports + +- High-side mode: `wall-ledger`/back channel, `independent-high-beam`, and later `reinforced-fascia` or `roof-plane-tie-in`. The first-party [Stratco attached-roof guide](https://www.stratco.com.au/siteassets/pdfs/patios_outback_flat_attached_install.pdf) illustrates wall, reinforced fascia, suspension, and over-roof attachment details, supporting an explicit mode rather than one generic connection. +- Ledger/high-beam dimensions and vertical offset; whether it is visible. +- Low beam dimensions, inset from the drip edge, and material. +- Post layout: count **or** target spacing, left/right setbacks, section/preset, material, and optional bracing. Post heights should derive from beam elevation and the support surface instead of being duplicated free values. +- Support-surface/footing references and a visual footing/post-base option. +- Framing strategy: hidden, rafters, purlin-like supports, or a covering-specific system; member dimensions, spacing, end inset, and material. + +### Weathering + +- High-side apron/counterflashing enabled, projection, and material. +- Left/right termination: open verge, wall abutment/flashing, or joined continuation. +- Low-eave gutter enabled, profile/size, outlets, and downspout positions. Prefer composing the existing gutter/downspout nodes over duplicating their schemas. +- Covering-specific minimum-pitch advisory. Treat warnings as product/jurisdiction guidance, not proof of compliance. + +### Placement and validation + +- Snap the high edge to a valid wall/facade interval, derive the outward normal, and preview the low beam/post row during placement. +- Reject or warn on collisions with the host roof/eave, adjacent buildings, wall openings, and neighboring extensions. +- Warn when a ledger is placed on fascia/rafter tails rather than a valid wall support, following the San Diego bulletin's attachment distinction. +- For a canopy between buildings, resolve both end abutments and drainage explicitly. +- Keep a clear visual distinction between **modeled appearance** and **structurally verified design**. + +## Suggested delivery order + +1. Prove the parametric plane, high/low elevation relationship, host-wall snap, low beam, and governed column row. +2. Add resize/move behavior in both 2D and 3D, with the selected dependency lock. +3. Compose the existing gutter/downspout system and add high-side/side flashing geometry. +4. Add exposed framing strategies and covering-specific advisories. +5. Consider roof-plane tie-ins and structural verification only as separately scoped capabilities. + +## Source-quality note + +The construction sources above are official government guidance, an official model-code publication, and first-party roofing-system specifications. Their numeric requirements are examples tied to a jurisdiction or product. They support the assembly model and validation vocabulary; they should not be copied into Pascal as universal engineering defaults.