diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 0440e1d1ea..ea5054e7e4 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -13,7 +13,7 @@ import { } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Tooltip, TooltipContent, @@ -90,6 +90,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 || @@ -171,13 +172,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() @@ -209,7 +209,15 @@ export function BuildTab() { const floorplanMode = useFloorplanMode((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) - const buildTypes = useMemo(() => collectBuildTypes(floorplanMode), [floorplanMode]) + const [registryReady, setRegistryReady] = useState(false) + const buildTypes = useMemo( + () => (registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES), + [floorplanMode, registryReady], + ) + + useEffect(() => { + setRegistryReady(true) + }, []) // The fitting / follow tools are armed from a segment's panel, not a grid // tile — keep the segment tile lit so the panel (and the way back) stays @@ -233,9 +241,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. @@ -248,7 +262,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 @@ -352,7 +366,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 @@ -298,6 +300,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 29b23c209e..eb956ea11c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,7 @@ export type { GuideEvent, GutterEvent, ItemEvent, + LeanToExtensionEvent, LevelEvent, MeasurementEvent, NodeEvent, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 08d459be3c..34a7a3c045 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -149,6 +149,7 @@ export type { SurfaceRole, SurfacesConfig, SystemContribution, + ToolContributionProps, ToolHint, ToolHintChip, Vec2, 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 d85da0d846..ec4419ace8 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -792,6 +792,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 } @@ -874,6 +876,7 @@ export type FloorplanMoveTargetSession = { export type FloorplanMoveTarget = (args: { node: N nodes: Record + sceneApi?: SceneApi }) => FloorplanMoveTargetSession // ─── Plugin manifest ───────────────────────────────────────────────── @@ -1246,7 +1249,7 @@ export type NodeDefinition> = { */ ports?: (node: z.infer) => NodePort[] system?: SystemContribution - tool?: LazyComponent + tool?: ToolLazyComponent /** * Stage-D drag-affordance components — one per kind-owned editor mode * triggered by `useEditor` state. Component receives `{ node }` as its @@ -1399,6 +1402,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 @@ -1424,6 +1429,12 @@ export type IconRef = * boundary per icon. */ | { kind: 'component'; module: () => Promise<{ default: ComponentType }> } +export type ToolContributionProps = { + sceneApi: SceneApi + activeLevelId: AnyNodeId | null + selectNode: (nodeId: AnyNodeId) => void +} +export type ToolLazyComponent = () => Promise<{ default: ComponentType }> export type LazyComponent = () => Promise<{ default: ComponentType }> export type RendererSource = @@ -2134,7 +2145,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 @@ -2291,6 +2302,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 d11bdec7d1..85fd1802f2 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 { BoxVentNode } from './nodes/box-vent' export { BuildingNode } from './nodes/building' export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' @@ -86,7 +92,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' @@ -99,7 +110,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, @@ -119,6 +145,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' 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..669a805510 --- /dev/null +++ b/packages/core/src/schema/nodes/automatic-downspout.ts @@ -0,0 +1,290 @@ +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 +} + +function gutterPointInRoofFrame( + gutter: GutterNode, + segment: RoofSegmentNode | undefined, + offset: number, +): Point2D { + const gutterRotation = gutter.rotation ?? 0 + const localX = gutter.position[0] + Math.cos(gutterRotation) * offset + const localZ = gutter.position[2] - Math.sin(gutterRotation) * offset + 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 +} + +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: Point2D = [ + gutter.position[0] + + Math.cos(gutterRotation) * outletOffset + + Math.sin(gutterRotation) * gutterFloorMidZ(gutter), + gutter.position[2] - + Math.sin(gutterRotation) * outletOffset + + Math.cos(gutterRotation) * gutterFloorMidZ(gutter), + ] + 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) + + 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..ed97db6572 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 @@ -88,3 +114,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..0232307e38 --- /dev/null +++ b/packages/core/src/schema/nodes/lean-to-extension.ts @@ -0,0 +1,102 @@ +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) +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), + 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'), + 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('count'), + postSpacing: z.number().min(0.3).max(10).default(2), + postInset: z.number().min(0).max(3).default(0.2), + 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/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 70575bcfb9..6c608a42e5 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -20,6 +20,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' @@ -50,6 +51,7 @@ export const AnyNode = z.discriminatedUnion('type', [ BuildingNode, ElevatorNode, LevelNode, + LeanToExtensionNode, ColumnNode, ConstructionDimensionNode, StructuralGridNode, 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 c8d4472752..4380588438 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,349 @@ 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[] +} + +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?.shift() + + 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 +1156,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 +1196,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 +1222,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 +1237,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 +1245,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 +1282,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 +1295,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 +1330,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 +1371,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 +1386,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 +1408,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 +1417,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 +1464,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 } @@ -1060,6 +1497,9 @@ const updateNodesActionImpl = ( for (const id of extraNodesToUpdate) { pendingUpdates.add(id) } + for (const id of extraNodesToDelete) { + get().clearDirty(id) + } if (pendingRafId !== null) { cancelAnimationFrame(pendingRafId) @@ -1111,6 +1551,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 @@ -1213,7 +1661,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 ba07c0c15f..a1862680f3 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 @@ -1044,6 +1044,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() @@ -1087,6 +1088,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/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/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 08259b6f4a..b5834f4a93 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -3,9 +3,11 @@ import { type AnyNodeId, type BuildingNode, type CeilingNode, + createSceneApi, type FenceNode, nodeRegistry, type SlabNode, + type ToolContributionProps, useScene, type WallNode, } from '@pascal-app/core' @@ -41,7 +43,8 @@ import { ZoneTool } from './zone/zone-tool' // Cache lazy tool components keyed by their loader so React.lazy isn't // re-invoked across renders. -const lazyToolCache = new WeakMap<() => Promise, ComponentType>() +type RegistryToolProps = ToolContributionProps +const lazyToolCache = new WeakMap<() => Promise, ComponentType>() const registryToolPreloadCache = new WeakMap>() export function preloadRegistryToolModules(tool: string | null): Promise { @@ -66,7 +69,7 @@ export function preloadRegistryToolModules(tool: string | null): Promise { return preload } -function getRegistryTool(tool: Tool | null): ComponentType | null { +function getRegistryTool(tool: Tool | null): ComponentType | null { if (!tool) return null const def = nodeRegistry.get(tool) if (!def?.tool) return null @@ -77,7 +80,7 @@ function getRegistryTool(tool: Tool | null): ComponentType | null { // inspector, and move contribution are warm. This keeps the click itself // synchronous even under Next.js dev-time on-demand compilation. await preloadRegistryToolModules(tool) - return def.tool!() as Promise<{ default: ComponentType }> + return def.tool!() as Promise<{ default: ComponentType }> }) lazyToolCache.set(def.tool, Comp) return Comp @@ -99,6 +102,7 @@ const tools: Record>> = { } export const ToolManager: React.FC = () => { + const sceneApi = useMemo(() => createSceneApi(useScene), []) const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) @@ -377,7 +381,11 @@ export const ToolManager: React.FC = () => { NodeDefinition with a tool contribution, mount it here. */} {!movingNode && useRegistryTool && RegistryToolComponent && ( - + )} {!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && ( diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index ff1f2af62f..cb2c19a9a9 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -152,6 +152,10 @@ export function HelperManager() { mode, tool, profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile, + profileOfNode: (nodeId) => { + const node = useScene.getState().nodes[nodeId as AnyNodeId] + return node ? nodeRegistry.get(node.type)?.snapProfile : undefined + }, draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true, }), [scope, mode, tool], @@ -212,6 +216,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/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index 3b96d3d393..701a28ac09 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -67,7 +67,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..585c989f08 --- /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 { hasTreeNodeComponent } from './tree-node' + +describe('site tree node routing', () => { + test('renders lean-to extensions in the editor tree', () => { + expect(hasTreeNodeComponent('lean-to-extension')).toBe(true) + }) +}) 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 8254c83680..ea412034a5 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 @@ -162,6 +162,7 @@ const treeNodeByType: Record< wall: WallTreeNode, fence: FenceTreeNode, gutter: GutterTreeNode, + 'lean-to-extension': RegistryTreeNode, measurement: RegistryTreeNode, 'ridge-vent': RegistryTreeNode, 'turbine-vent': RegistryTreeNode, @@ -180,6 +181,10 @@ const treeNodeByType: Record< item: ItemTreeNode, } +export function hasTreeNodeComponent(nodeType: string): boolean { + return treeNodeByType[nodeType] !== undefined +} + 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. diff --git a/packages/nodes/src/downspout/definition.ts b/packages/nodes/src/downspout/definition.ts index a2628fb959..2a78f5322a 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'), 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/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index 7f40834734..14a478613e 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({ 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/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/index.ts b/packages/nodes/src/index.ts index b9040fba74..697ee38058 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -20,6 +20,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' @@ -69,6 +70,7 @@ export const builtinPlugin: Plugin = { shelfDefinition 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, @@ -149,6 +151,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/assembly.test.ts b/packages/nodes/src/lean-to-extension/assembly.test.ts new file mode 100644 index 0000000000..4406330709 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/assembly.test.ts @@ -0,0 +1,303 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + BuildingNode, + getRoofSegmentVisibleTopBounds, + LeanToExtensionNode, + LevelNode, + RoofNode, + resolveAutomaticDownspoutLength, + SlabNode, + spatialGridManager, + WallNode, +} from '@pascal-app/core' +import { getRoofTopSurfaceY } from '../shared/roof-surface' +import { + createLeanToAssembly, + isManagedLeanToNode, + isManagedLeanToPost, + leanToDownspoutLayoutPatch, + leanToGutterLayoutPatch, + leanToPostLayoutPatch, + managedLeanToPostSide, + resolveLeanToPostBaseY, + resolveLeanToPostGutterSetback, +} from './assembly' +import { resolveLeanToLayout } from './layout' + +beforeEach(() => spatialGridManager.clear()) + +describe('lean-to assembly', () => { + test('composes a standard shed roof, gutter, downspout, and pillar children', () => { + const leanTo = LeanToExtensionNode.parse({ + 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.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.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('composes terrain-aware high-side columns for an independent beam', () => { + const leanTo = LeanToExtensionNode.parse({ + highSideMode: 'independent-high-beam', + 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('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..5d32298cdc --- /dev/null +++ b/packages/nodes/src/lean-to-extension/assembly.ts @@ -0,0 +1,489 @@ +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 { resolveLeanToLayout } from './layout' + +const MANAGED_BY_KEY = 'managedByLeanTo' +const MANAGED_ROLE_KEY = 'leanToRole' +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 + +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, + ...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) + return { + position: [ + layout.postXs[index] ?? 0, + baseY, + side === 'high' ? 0 : layout.beamZ - gutterSetback, + ], + rotation: 0, + 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 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 levelId = wall.parentId + if (!levelId || nodes[levelId]?.type !== 'level') return 0 + + const layout = resolveLeanToLayout(leanTo) + const postX = layout.postXs[index] ?? 0 + const leanRotation = leanTo.rotation[1] + const leanCos = Math.cos(leanRotation) + const leanSin = Math.sin(leanRotation) + const postZ = side === 'high' ? 0 : layout.beamZ + 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 type LeanToRoofSegmentLayoutPatch = Pick< + RoofSegmentNodeType, + | 'position' + | 'rotation' + | 'roofType' + | 'width' + | 'depth' + | 'wallHeight' + | 'pitch' + | 'wallThickness' + | 'deckThickness' + | 'shingleThickness' + | 'overhang' + | 'trim' +> + +export function leanToRoofSegmentLayoutPatch( + leanTo: LeanToExtensionNode, +): LeanToRoofSegmentLayoutPatch { + const layout = resolveLeanToLayout(leanTo) + const shingleThickness = leanTo.shingleThickness ?? 0.025 + const overhang = 0 + const width = layout.roofWidth + const depth = layout.roofRun + WALL_CONNECTION_OVERLAP + 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: [ + layout.roofCenterX, + layout.highEdgeHeight - topAtWall, + depth / 2 - Math.max(0, leanTo.highOverhang) - WALL_CONNECTION_TRIM - WALL_CONNECTION_OVERLAP, + ], + rotation: 0, + roofType: 'shed', + width, + depth, + wallHeight: 0, + pitch: layout.effectivePitchDegrees, + wallThickness: 0.01, + deckThickness: leanTo.roofThickness, + shingleThickness, + overhang, + 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, +): Pick< + GutterNodeType, + 'position' | 'rotation' | 'length' | 'roofSegmentId' | 'visible' | 'profile' | 'size' | 'outlets' +> { + const snap = resolveEaveSnap(segment, 0, segment.depth / 2) + const length = segment.width + 2 * segment.overhang + const existingOutlet = gutter?.outlets[0] + const outletId = existingOutlet?.id ?? generateId('outlet') + const offset = leanTo.downspoutPosition * Math.max(0, length / 2 - 0.16) + 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, + roofSegmentId: segment.id, + visible: leanTo.gutterEnabled, + profile: leanTo.gutterProfile, + size: leanTo.gutterSize, + outlets: leanTo.gutterEnabled && leanTo.downspoutEnabled ? [outlet] : [], + } +} + +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, +): 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), + name: 'Lean-to Shed Roof', + parentId: roof.id, + metadata: managedMetadata(leanTo, 'roof-segment'), + }) + const gutter = GutterNode.parse({ + ...leanToGutterLayoutPatch(segment, leanTo), + name: 'Lean-to Gutter', + parentId: segment.id, + metadata: managedMetadata(leanTo, 'gutter'), + }) + 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, +): { + extension: LeanToExtensionNode + roof: RoofNodeType + segment: RoofSegmentNodeType + gutter: GutterNodeType + downspout: DownspoutNodeType + posts: ColumnNodeType[] + children: AnyNode[] +} { + const roofAssembly = createManagedLeanToRoofAssembly(leanTo, hostRoof) + const postCount = resolveLeanToLayout(leanTo).postXs.length + const posts = Array.from({ length: postCount }, (_, index) => + createManagedLeanToPost(leanTo, index, 'low'), + ) + if (leanTo.highSideMode === 'independent-high-beam') { + posts.push( + ...Array.from({ length: postCount }, (_, index) => + createManagedLeanToPost(leanTo, index, 'high'), + ), + ) + } + const children: AnyNode[] = [ + roofAssembly.roof, + roofAssembly.segment, + roofAssembly.gutter, + roofAssembly.downspout, + ...posts, + ] + return { + extension: { + ...leanTo, + children: [roofAssembly.roof.id, ...posts.map((post) => post.id)], + }, + ...roofAssembly, + posts, + children, + } +} 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..2fac435474 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/definition.ts @@ -0,0 +1,177 @@ +import type { + AnyNodeId, + HandleDescriptor, + NodeDefinition, + SceneApi, + WallNode, +} from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { buildLeanToExtensionFloorplan } from './floorplan' +import { leanToResizeAffordance } from './floorplan-affordances' +import { leanToFloorplanMoveTarget } from './floorplan-move' +import { buildLeanToExtensionGeometry, leanToExtensionGeometryKey } from './geometry' +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 ROOF_EDGE_SNAP_TOLERANCE = 0.3 +const PROJECTION_HANDLE_HEIGHT = 0.2 + +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', + } +} + +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) => [0, PROJECTION_HANDLE_HEIGHT, node.projection] }, + measureLabel: 'Projection', +}) +leanToExtensionHandles.push({ + kind: 'linear-resize', + axis: 'x', + anchor: 'center', + min: 0.5, + max: 100, + currentValue: (node) => node.span, + apply: (_node, span) => ({ span, autoSpan: false }), + placement: { position: (node) => [node.span / 2, PROJECTION_HANDLE_HEIGHT, node.projection] }, + measureLabel: 'Span', +}) + +export const leanToExtensionDefinition: NodeDefinition = { + kind: 'lean-to-extension', + schemaVersion: 6, + 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, + 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..f011e53991 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan-affordances.ts @@ -0,0 +1,56 @@ +import { + type AnyNodeId, + type FloorplanAffordance, + 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 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 outwardSign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 + const outward: readonly [number, number] = [-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 + let lastPatch: Partial = {} + + return { + affectedIds: [node.id as AnyNodeId], + apply({ planPoint }) { + const currentAxis = planPoint[0] * axis[0] + planPoint[1] * axis[1] + const multiplier = dimension === 'projection' ? 1 : 2 + const raw = initialValue + (currentAxis - initialAxis) * side * multiplier + 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 } + 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..105f6ea191 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan-move.ts @@ -0,0 +1,60 @@ +import { + type AnyNode, + type AnyNodeId, + type FloorplanMoveTarget, + type LeanToExtensionNode, + useLiveNodeOverrides, + type WallNode, +} from '@pascal-app/core' +import { getSegmentGridStep } from '@pascal-app/editor' +import { resolveLeanToMoveCenterX } from './layout' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' + +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 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 rawLocalX = + ((planPoint[0] - wall.start[0]) * dx + (planPoint[1] - wall.start[1]) * dz) / length + const step = modifiers.altKey ? 0 : getSegmentGridStep() + const position: LeanToExtensionNode['position'] = [ + resolveLeanToMoveCenterX(node, wall, rawLocalX, step), + node.position[1], + node.position[2], + ] + const nodes = sceneApi.nodes() as Record + 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..d3ed22ca3e --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx @@ -0,0 +1,175 @@ +'use client' + +import type { AnyNode, AnyNodeId } 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 { createLeanToAssembly } from './assembly' +import { resolveLeanToWallPlacement } from './layout' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' +import { + 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 attachedNode = attachment + ? applyLeanToRoofAttachment(wallPlacement, attachment) + : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), hit.wall) + 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)) + 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 dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + const dirX = dx / length + const dirZ = dz / length + const perpX = -dirZ + const perpZ = dirX + const sign = Math.cos(target.rotation[1]) >= 0 ? 1 : -1 + const originX = wall.start[0] + dirX * target.position[0] + perpX * target.position[2] + const originZ = wall.start[1] + dirZ * target.position[0] + perpZ * target.position[2] + const outX = perpX * sign + const outZ = perpZ * sign + 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 points = [ + [originX - dirX * left - outX * high, originZ - dirZ * left - outZ * high], + [originX + dirX * right - outX * high, originZ + dirZ * right - outZ * high], + [originX + dirX * right + outX * low, originZ + dirZ * right + outZ * low], + [originX - dirX * left + outX * low, originZ - dirZ * left + outZ * low], + ] + + 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.ts b/packages/nodes/src/lean-to-extension/floorplan.ts new file mode 100644 index 0000000000..0707a903d5 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan.ts @@ -0,0 +1,110 @@ +import type { + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + LeanToExtensionNode, + WallNode, +} from '@pascal-app/core' +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 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 dirX = dx / length + const dirZ = dz / length + const perpX = -dirZ + const perpZ = dirX + const outwardSign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 + const originX = wall.start[0] + dirX * node.position[0] + perpX * node.position[2] + const originZ = wall.start[1] + dirZ * node.position[0] + perpZ * node.position[2] + const layout = resolveLeanToLayout(node) + const outX = perpX * outwardSign + const outZ = perpZ * outwardSign + 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 points: readonly FloorplanPoint[] = [ + [originX - dirX * left - outX * high, originZ - dirZ * left - outZ * high], + [originX + dirX * right - outX * high, originZ + dirZ * right - outZ * high], + [originX + dirX * right + outX * low, originZ + dirZ * right + outZ * low], + [originX - dirX * left + outX * low, originZ - dirZ * left + outZ * low], + ] + const beamX = originX + outX * layout.beamZ + const beamZ = originZ + outZ * layout.beamZ + 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', + }, + { + kind: 'line', + x1: beamX - (dirX * layout.span) / 2, + y1: beamZ - (dirZ * layout.span) / 2, + x2: beamX + (dirX * layout.span) / 2, + y2: beamZ + (dirZ * layout.span) / 2, + stroke, + strokeWidth: selected ? 3 : 2, + vectorEffect: 'non-scaling-stroke', + }, + ] + + for (const x of layout.postXs) { + const postX = originX + dirX * x + outX * layout.beamZ + const postZ = originZ + dirZ * x + outZ * 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 = originX + outX * (layout.roofRun + arrowOffset) + const eaveZ = originZ + outZ * (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 + children.push({ + kind: 'move-arrow', + point: [originX + dirX * x + outX * layout.beamZ, originZ + dirZ * x + outZ * layout.beamZ], + angle: Math.atan2(dirZ * side, dirX * side), + 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..800ffd0e7f --- /dev/null +++ b/packages/nodes/src/lean-to-extension/geometry.test.ts @@ -0,0 +1,163 @@ +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 } from 'three' +import { buildGutterGeometry } from '../gutter/geometry' +import { createLeanToAssembly } from './assembly' +import { buildLeanToExtensionGeometry } from './geometry' +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) + }) +}) 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..7e66df24fd --- /dev/null +++ b/packages/nodes/src/lean-to-extension/geometry.ts @@ -0,0 +1,369 @@ +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 } from 'three' +import { LEAN_TO_EXTENSION_GEOMETRY_REVISION, resolveLeanToLayout } from './layout' +import { LEAN_TO_SLOT_DEFAULTS, type LeanToSlotId } from './slots' + +export function leanToExtensionGeometryKey(node: LeanToExtensionNode): string { + return JSON.stringify([ + LEAN_TO_EXTENSION_GEOMETRY_REVISION, + node.span, + 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, + ]) +} + +function addBox( + group: Group, + args: { + name: string + size: [number, number, number] + position: [number, number, number] + rotationX?: 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) + mesh.rotation.x = args.rotationX ?? 0 + mesh.castShadow = true + mesh.receiveShadow = true + mesh.userData.surfaceRole = args.role + if (args.slotId) mesh.userData.slotId = args.slotId + 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 group = new Group() + group.name = 'lean-to-extension-geometry' + 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) { + addBox(group, { + name: 'lean-to-preview-roof', + size: [layout.roofWidth, node.roofThickness, layout.slopeLength], + position: [layout.roofCenterX, layout.roofCenterY, layout.roofCenterZ], + rotationX: layout.pitchRadians, + role: 'roof', + colorPreset, + sceneTheme, + }) + } + + if (node.highSideMode === 'independent-high-beam') { + addBox(group, { + name: 'lean-to-independent-high-beam', + size: [layout.span, node.ledgerHeight, node.ledgerDepth], + position: [ + 0, + layout.highEdgeHeight - + node.roofThickness / 2 - + node.ledgerHeight / 2 + + node.ledgerVerticalOffset, + 0, + ], + role: 'joinery', + colorPreset, + sceneTheme, + material: ledgerMaterial, + slotId: 'ledger', + }) + } + + if (node.sideFlashing) { + for (const [side, condition] of [ + [-1, node.leftEndCondition], + [1, node.rightEndCondition], + ] as const) { + if (condition === 'open') continue + addBox(group, { + name: `lean-to-${side < 0 ? 'left' : 'right'}-side-flashing`, + size: [node.flashingProjection, node.flashingHeight, layout.slopeLength], + position: [ + side < 0 ? -(layout.span / 2 + node.leftOverhang) : layout.span / 2 + node.rightOverhang, + layout.roofCenterY + node.flashingHeight / 3, + layout.roofCenterZ, + ], + rotationX: layout.pitchRadians, + role: 'roof', + colorPreset, + sceneTheme, + material: flashingMaterial, + slotId: 'flashing', + }) + } + } + + addBox(group, { + name: 'lean-to-front-beam', + size: [layout.beamSpan, node.beamHeight, node.beamWidth], + position: [0, layout.beamCenterY, layout.beamZ], + role: 'joinery', + colorPreset, + sceneTheme, + material: beamMaterial, + slotId: 'beam', + }) + + if (!ctx) { + for (const [index, x] of layout.postXs.entries()) { + addBox(group, { + name: `lean-to-post-${index}`, + size: [node.postWidth, layout.postHeight, node.postDepth], + position: [x, layout.postHeight / 2, layout.beamZ], + role: 'joinery', + colorPreset, + sceneTheme, + material: postsMaterial, + slotId: 'posts', + }) + if (node.footingStyle !== 'none') { + addBox(group, { + name: `lean-to-post-footing-${index}`, + size: [node.postWidth * footingScale, footingHeight, node.postDepth * footingScale], + position: [x, footingHeight / 2, layout.beamZ], + role: 'joinery', + colorPreset, + sceneTheme, + 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()) { + addBox(group, { + name: `lean-to-high-post-${index}`, + size: [node.postWidth, highPostHeight, node.postDepth], + position: [x, highPostHeight / 2, 0], + role: 'joinery', + colorPreset, + sceneTheme, + material: postsMaterial, + slotId: 'posts', + }) + if (node.footingStyle !== 'none') { + addBox(group, { + name: `lean-to-high-post-footing-${index}`, + size: [node.postWidth * footingScale, footingHeight, node.postDepth * footingScale], + position: [x, footingHeight / 2, 0], + role: 'joinery', + colorPreset, + sceneTheme, + material: footingsMaterial, + slotId: 'footings', + }) + } + } + } + + if (node.postBracing === 'knee') { + for (const [index, x] of layout.postXs.entries()) { + addBox(group, { + name: `lean-to-knee-brace-${index}`, + size: [node.rafterWidth, node.rafterHeight, Math.min(0.8, layout.projection / 2)], + position: [x, layout.beamCenterY - 0.22, Math.max(0, layout.beamZ - 0.22)], + rotationX: Math.PI / 4, + role: 'joinery', + colorPreset, + sceneTheme, + material: framingMaterial, + slotId: 'framing', + }) + } + } + + if (node.framingStrategy === 'rafters') { + for (const [index, x] of layout.rafterXs.entries()) { + addBox(group, { + name: `lean-to-rafter-${index}`, + size: [node.rafterWidth, node.rafterHeight, layout.rafterSlopeLength], + position: [x, layout.rafterCenterY, layout.rafterCenterZ], + rotationX: layout.pitchRadians, + 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) + addBox(group, { + name: `lean-to-purlin-${index}`, + size: [layout.roofWidth, node.purlinHeight, node.purlinWidth], + position: [layout.roofCenterX, y, z], + rotationX: layout.pitchRadians, + role: 'joinery', + colorPreset, + sceneTheme, + material: framingMaterial, + slotId: 'framing', + }) + } + } + + return group +} 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..8758425e30 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/layout.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'bun:test' +import { AnyNode, LeanToExtensionNode, RoofNode, WallNode } from '@pascal-app/core' +import { resolveLeanToLayout, resolveLeanToMoveCenterX, resolveLeanToWallPlacement } 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('rejects curved walls until tangent hosting is implemented', () => { + const wall = WallNode.parse({ start: [0, 0], end: [6, 0], curveOffset: 1 }) + expect(resolveLeanToWallPlacement(wall, 3, 'front')).toBeNull() + }) + + 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('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..4b414a4687 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/layout.ts @@ -0,0 +1,185 @@ +import { LeanToExtensionNode, type WallNode } from '@pascal-app/core' +import { EAVE_TUCK_INWARD } from '../gutter/eave-snap' + +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 = 5 + +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[] +} + +export function leanToLowEdgeHeight( + node: Pick, +): number { + return node.highEdgeHeight - node.projection * Math.tan((node.pitch * Math.PI) / 180) +} + +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) + + 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: evenlySpacedXs(span, rafterCount, node.rafterEndInset), + } +} + +export function resolveLeanToMoveCenterX( + node: LeanToExtensionNode, + wall: WallNode, + rawLocalX: number, + snapStep = 0, +): number { + const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + 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) + return max < min ? wallLength / 2 : Math.max(min, Math.min(max, snapped)) +} + +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 { + if (Math.abs(wall.curveOffset ?? 0) > 1e-6) return null + const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + 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), + }) + return { ...parsed, lowEdgeHeight: leanToLowEdgeHeight(parsed) } +} + +export function leanToWallLocalPose( + wall: WallNode, + node: LeanToExtensionNode, + baseY: number, +): { position: [number, number, number]; rotationY: number } { + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const [localX, localY, localZ] = node.position + return { + position: [ + wall.start[0] + localX * Math.cos(angle) - localZ * Math.sin(angle), + baseY + localY, + wall.start[1] + localX * Math.sin(angle) + localZ * Math.cos(angle), + ], + rotationY: -angle + node.rotation[1], + } +} 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..f592d4cb92 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/move-tool.tsx @@ -0,0 +1,88 @@ +'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 { 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 position: LeanToExtensionNode['position'] = [ + resolveLeanToMoveCenterX(node, wall, rawLocalX, gridStep), + node.position[1], + node.position[2], + ] + const nodes = sceneApi.nodes() as Record + 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..5dcca9d90e --- /dev/null +++ b/packages/nodes/src/lean-to-extension/parametrics.ts @@ -0,0 +1,479 @@ +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: 'Roof', + fields: [ + { + key: 'connectionMode', + kind: 'enum', + options: ['auto', 'manual'], + display: 'segmented', + }, + { + key: 'highSideMode', + kind: 'enum', + options: ['wall-ledger', 'independent-high-beam'], + }, + { + key: 'ledgerVerticalOffset', + kind: 'number', + unit: 'm', + min: -1, + max: 1, + step: 0.01, + visibleIf: (node) => node.highSideMode === 'independent-high-beam', + }, + { + key: 'ledgerDepth', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.5, + step: 0.01, + visibleIf: (node) => node.highSideMode === 'independent-high-beam', + }, + { + key: 'ledgerHeight', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.8, + step: 0.01, + visibleIf: (node) => node.highSideMode === 'independent-high-beam', + }, + { key: 'autoSpan', kind: 'boolean' }, + { + key: 'span', + kind: 'number', + unit: 'm', + min: 0.5, + max: 100, + step: 0.1, + }, + { + key: 'projection', + kind: 'number', + unit: 'm', + min: 0.5, + max: 10, + step: 0.1, + }, + { + key: 'resizeLock', + kind: 'enum', + options: ['preserve-high-edge', 'preserve-low-edge', 'preserve-pitch'], + }, + { + key: 'highEdgeHeight', + kind: 'number', + unit: 'm', + min: 0.8, + max: 10, + step: 0.05, + visibleIf: (node) => node.connectionMode === 'manual' || !node.hostRoofSegmentId, + }, + { + key: 'lowEdgeHeight', + kind: 'number', + unit: 'm', + min: 0.2, + max: 10, + step: 0.05, + visibleIf: (node) => node.connectionMode === 'manual' || !node.hostRoofSegmentId, + }, + { + key: 'connectionOffset', + kind: 'number', + unit: 'm', + min: -1, + max: 1, + step: 0.01, + visibleIf: (node) => node.connectionMode === 'auto' && Boolean(node.hostRoofSegmentId), + }, + { + key: 'matchHostRoofMaterial', + kind: 'boolean', + visibleIf: (node) => node.connectionMode === 'auto' && Boolean(node.hostRoofId), + }, + { + key: 'matchHostRoofStructure', + kind: 'boolean', + visibleIf: (node) => node.connectionMode === 'auto' && Boolean(node.hostRoofId), + }, + { + key: 'roofThickness', + kind: 'number', + unit: 'm', + min: 0.02, + max: 0.5, + step: 0.01, + }, + { + key: 'shingleThickness', + kind: 'number', + unit: 'm', + min: 0, + max: 0.5, + step: 0.005, + }, + { key: 'pitch', kind: 'number', unit: '°', min: 1, max: 45, step: 1 }, + { + key: 'highOverhang', + kind: 'number', + unit: 'm', + min: 0, + max: 1.5, + step: 0.05, + }, + { + key: 'lowOverhang', + kind: 'number', + unit: 'm', + min: 0, + max: 1.5, + step: 0.05, + }, + { + key: 'leftOverhang', + kind: 'number', + unit: 'm', + min: 0, + max: 1.5, + step: 0.05, + }, + { + key: 'rightOverhang', + kind: 'number', + unit: 'm', + min: 0, + max: 1.5, + step: 0.05, + }, + { + key: 'coveringType', + kind: 'enum', + options: ['generic', 'shingle', 'metal-panel'], + }, + { key: 'sideFlashing', kind: 'boolean' }, + { + key: 'flashingProjection', + kind: 'number', + unit: 'm', + min: 0.01, + max: 0.5, + step: 0.005, + visibleIf: (node) => node.sideFlashing, + }, + { + key: 'flashingHeight', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.5, + step: 0.01, + visibleIf: (node) => node.sideFlashing, + }, + { + key: 'leftEndCondition', + kind: 'enum', + options: ['open', 'wall-abutment', 'joined'], + }, + { + key: 'rightEndCondition', + kind: 'enum', + options: ['open', 'wall-abutment', 'joined'], + }, + ], + }, + { + label: 'Structure', + fields: [ + { + key: 'framingStrategy', + kind: 'enum', + options: ['hidden', 'rafters', 'purlins', 'covering-specific'], + }, + { + key: 'rafterWidth', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.4, + step: 0.01, + visibleIf: (node) => node.framingStrategy === 'rafters', + }, + { + key: 'rafterHeight', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.5, + step: 0.01, + }, + { + key: 'rafterSpacing', + kind: 'number', + unit: 'm', + min: 0.2, + max: 3, + step: 0.05, + visibleIf: (node) => node.framingStrategy === 'rafters', + }, + { + key: 'rafterEndInset', + kind: 'number', + unit: 'm', + min: 0, + max: 3, + step: 0.05, + visibleIf: (node) => node.framingStrategy === 'rafters', + }, + { + key: 'purlinWidth', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.4, + step: 0.01, + visibleIf: (node) => + node.framingStrategy === 'purlins' || node.framingStrategy === 'covering-specific', + }, + { + key: 'purlinHeight', + kind: 'number', + unit: 'm', + min: 0.03, + max: 0.5, + step: 0.01, + visibleIf: (node) => + node.framingStrategy === 'purlins' || node.framingStrategy === 'covering-specific', + }, + { + key: 'purlinSpacing', + kind: 'number', + unit: 'm', + min: 0.2, + max: 3, + step: 0.05, + visibleIf: (node) => + node.framingStrategy === 'purlins' || node.framingStrategy === 'covering-specific', + }, + { + key: 'postCount', + kind: 'number', + min: 2, + max: 20, + step: 1, + visibleIf: (node) => node.postLayoutMode === 'count', + }, + { + key: 'postLayoutMode', + kind: 'enum', + options: ['count', 'target-spacing'], + }, + { + key: 'postSpacing', + kind: 'number', + unit: 'm', + min: 0.3, + max: 10, + step: 0.1, + visibleIf: (node) => node.postLayoutMode === 'target-spacing', + }, + { + key: 'postInset', + kind: 'number', + unit: 'm', + min: 0, + max: 3, + step: 0.05, + }, + { + key: 'postWidth', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.6, + step: 0.01, + }, + { + key: 'postDepth', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.6, + step: 0.01, + }, + { + key: 'beamHeight', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.8, + step: 0.01, + }, + { + key: 'beamWidth', + kind: 'number', + unit: 'm', + min: 0.05, + max: 0.6, + step: 0.01, + }, + { + key: 'lowBeamInset', + kind: 'number', + unit: 'm', + min: 0, + max: 2, + step: 0.05, + }, + { key: 'postBracing', kind: 'enum', options: ['none', 'knee'] }, + { + key: 'footingStyle', + kind: 'enum', + options: ['none', 'base-plate', 'concrete-pad'], + }, + ], + }, + { + label: 'Drainage', + fields: [ + { key: 'gutterEnabled', kind: 'boolean' }, + { + key: 'gutterProfile', + kind: 'enum', + options: ['k-style', 'half-round', 'box'], + visibleIf: (node) => node.gutterEnabled, + }, + { + key: 'gutterSize', + kind: 'number', + unit: 'm', + min: 0.04, + max: 0.3, + step: 0.01, + visibleIf: (node) => node.gutterEnabled, + }, + { + key: 'downspoutEnabled', + kind: 'boolean', + visibleIf: (node) => node.gutterEnabled, + }, + { + key: 'downspoutPosition', + kind: 'number', + min: -1, + max: 1, + step: 0.05, + visibleIf: (node) => node.gutterEnabled && node.downspoutEnabled, + }, + ], + }, + ], + 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..cb02a767fa --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement-validation.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + BuildingNode, + LeanToExtensionNode, + LevelNode, + RoofNode, + RoofSegmentNode, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' + +describe('lean-to placement validation', () => { + test('rejects 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(1) + }) + + 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('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('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..f2e26ab57d --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement-validation.ts @@ -0,0 +1,393 @@ +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + getActiveRoofHeight, + getLevelElevations, + type LeanToExtensionNode, + type RoofNode, + type RoofSegmentNode, + type WallNode, +} from '@pascal-app/core' +import { resolveLeanToLayout } from './layout' + +const CLEARANCE = 0.05 + +function overlaps(aCenter: number, aWidth: number, bCenter: number, bWidth: number) { + return Math.abs(aCenter - bCenter) < (aWidth + bWidth) / 2 + CLEARANCE +} + +function planBounds(leanTo: LeanToExtensionNode, wall: WallNode) { + 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 = [dx / length, dz / length] as const + const normal = [-along[1], along[0]] as const + const side = Math.cos(leanTo.rotation[1]) >= 0 ? 1 : -1 + const outward = [normal[0] * side, normal[1] * side] as const + const center = [ + 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], + ] as const + const minAlong = -leanTo.span / 2 - leanTo.leftOverhang + const maxAlong = leanTo.span / 2 + leanTo.rightOverhang + const minOutward = -leanTo.highOverhang + const maxOutward = leanTo.projection + leanTo.lowOverhang + const points = [minAlong, maxAlong].flatMap((x) => + [minOutward, maxOutward].map((z) => [ + center[0] + along[0] * x + outward[0] * z, + center[1] + along[1] * x + outward[1] * z, + ]), + ) + 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 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 === 'door' || child.type === 'window') { + if (overlaps(leanTo.position[0], leanTo.span, child.position[0], child.width)) { + conflicts.push(`${child.type} ${child.id}`) + } + continue + } + if ( + child.type === 'lean-to-extension' && + Math.cos(child.rotation[1]) * Math.cos(leanTo.rotation[1]) > 0 && + overlaps(leanTo.position[0], leanTo.span, child.position[0], child.span) + ) { + 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 + if ( + host?.type === 'wall' && + boundsOverlap( + candidateWorldBounds, + transformBounds(planBounds(node, host), ancestorBuilding(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 + 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 (!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/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/roof-attachment.test.ts b/packages/nodes/src/lean-to-extension/roof-attachment.test.ts new file mode 100644 index 0000000000..b77b281bbb --- /dev/null +++ b/packages/nodes/src/lean-to-extension/roof-attachment.test.ts @@ -0,0 +1,269 @@ +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 { + 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('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..4e5296c98b --- /dev/null +++ b/packages/nodes/src/lean-to-extension/roof-attachment.ts @@ -0,0 +1,362 @@ +import { + type AnyNode, + type AnyNodeId, + getLevelElevations, + getWallBaseElevationForNodes, + 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 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 side = Math.cos(leanTo.rotation[1]) >= 0 ? 1 : -1 + 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, + halfSpan: number, +): readonly [number, number] { + const overlapFrom = Math.max(-halfSpan, Math.min(edgeStart, edgeEnd)) + const overlapTo = Math.min(halfSpan, 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 + const halfSpan = + leanTo.span / 2 + Math.max(Math.max(0, leanTo.leftOverhang), Math.max(0, leanTo.rightOverhang)) + 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(halfSpan, edgeMax) - Math.max(-halfSpan, 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, halfSpan), + 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 = 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 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/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..ff154959dc --- /dev/null +++ b/packages/nodes/src/lean-to-extension/system.test.ts @@ -0,0 +1,84 @@ +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 } 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() + }) +}) 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..355ee3a5a0 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/system.tsx @@ -0,0 +1,518 @@ +'use client' + +import type { + AnyNode, + AnyNodeId, + ColumnNode, + DownspoutNode, + GutterNode, + LeanToExtensionNode, + RoofNode, + RoofSegmentNode, + SceneApi, + WallNode, +} from '@pascal-app/core' +import { useEffect } from 'react' +import { + createManagedLeanToPost, + createManagedLeanToRoofAssembly, + isManagedLeanToNode, + isManagedLeanToPost, + type LeanToPostSide, + leanToDownspoutLayoutPatch, + leanToGutterLayoutPatch, + leanToPostLayoutPatch, + leanToRoofMaterialPatch, + leanToRoofSegmentLayoutPatch, + managedLeanToPostIndex, + managedLeanToPostSide, + resolveLeanToPostBaseY, + resolveLeanToPostGutterSetback, +} from './assembly' +import { LEAN_TO_EXTENSION_GEOMETRY_REVISION, resolveLeanToLayout } from './layout' +import { resolveLeanToEndAbutments } from './placement-validation' +import { + applyLeanToRoofAttachment, + applyLeanToWallAutoSpan, + clearLeanToRoofAttachment, + resolveLeanToHostRoof, + resolveLeanToRoofAttachment, +} from './roof-attachment' + +const ROOF_EDGE_REATTACH_TOLERANCE = 0.3 +const BROAD_LEAN_TO_DEPENDENCY_TYPES = new Set([ + 'site', + 'building', + 'level', + 'slab', + 'wall', + '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 ( + !sameTuple(post.position, expected.position) || + post.rotation !== expected.rotation || + 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) { + const expected = leanToRoofSegmentLayoutPatch(leanTo) + 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.trim) !== JSON.stringify(expected.trim) + ) +} + +function gutterNeedsLayoutUpdate( + gutter: GutterNode, + segment: RoofSegmentNode, + leanTo: LeanToExtensionNode, +) { + const expected = leanToGutterLayoutPatch(segment, leanTo, gutter) + return ( + !sameTuple(gutter.position, expected.position) || + gutter.rotation !== expected.rotation || + gutter.length !== expected.length || + gutter.roofSegmentId !== expected.roofSegmentId || + gutter.visible !== expected.visible || + gutter.profile !== expected.profile || + gutter.size !== expected.size || + JSON.stringify(gutter.outlets) !== JSON.stringify(expected.outlets) + ) +} + +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 + ) +} + +function extensionSignature( + leanTo: LeanToExtensionNode, + hostRoof: RoofNode | undefined, + nodes: Record, +): string { + return JSON.stringify([ + leanTo.span, + 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.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, + 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 || + !sameTuple(current.position, next.position) || + current.roofThickness !== next.roofThickness || + current.shingleThickness !== next.shingleThickness + ) +} + +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) + const resolved = + leanTo.connectionMode === 'manual' + ? attachment && + Math.abs(attachment.highEdgeHeight - leanTo.highEdgeHeight) <= ROOF_EDGE_REATTACH_TOLERANCE + ? applyLeanToRoofAttachment(leanTo, attachment) + : wallSpanningLeanTo + : attachment + ? applyLeanToRoofAttachment(leanTo, attachment) + : clearLeanToRoofAttachment(wallSpanningLeanTo) + return resolveLeanToEndAbutments(resolved, wall, nodes) +} + +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, + position: effectiveLeanTo.position, + roofThickness: effectiveLeanTo.roofThickness, + shingleThickness: effectiveLeanTo.shingleThickness, + } as Partial, + }) + } + + if (!roof) { + const assembly = createManagedLeanToRoofAssembly(effectiveLeanTo, hostRoof) + 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) + const expectedSegment = { + ...segment, + ...segmentPatch, + } as RoofSegmentNode + if (segmentNeedsLayoutUpdate(segment, effectiveLeanTo)) { + 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) + const expectedGutter = { ...gutter, ...gutterPatch } as GutterNode + if (gutterNeedsLayoutUpdate(gutter, expectedSegment, effectiveLeanTo)) { + 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 resolvedPostCount = resolveLeanToLayout(effectiveLeanTo).postXs.length + const postSides: LeanToPostSide[] = + effectiveLeanTo.highSideMode === 'independent-high-beam' ? ['low', 'high'] : ['low'] + const desiredPostKeys = new Set() + for (const side of postSides) { + for (let index = 0; index < resolvedPostCount; index++) { + 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) + ) { + update.push({ + id: current.id as AnyNodeId, + data: leanToPostLayoutPatch( + effectiveLeanTo, + index, + postBaseY, + gutterSetback, + side, + ) 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..ddbc74f43a --- /dev/null +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -0,0 +1,141 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + emitter, + getLevelElevations, + getWallBaseElevationForNodes, + type ToolContributionProps, + type WallEvent, + type WallNode, +} from '@pascal-app/core' +import { + getSideFromNormal, + isValidWallSideFace, + triggerSFX, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { useEffect, useState } from 'react' +import { createLeanToAssembly } from './assembly' +import { leanToExtensionGeometryKey } from './geometry' +import { leanToWallLocalPose, resolveLeanToWallPlacement } from './layout' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' +import LeanToExtensionPreview from './preview' +import { + 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 = ({ activeLevelId, sceneApi, selectNode }: ToolContributionProps) => { + 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) => { + if (!isValidWallSideFace(event.normal)) { + setPreview(null) + return null + } + const wallPlacement = resolveLeanToWallPlacement( + event.node, + event.localPosition[0], + getSideFromNormal(event.normal), + ) + if (!wallPlacement) { + setPreview(null) + return null + } + const nodes = sceneApi.nodes() as Record + const attachment = resolveLeanToRoofAttachment(wallPlacement, event.node, nodes) + const attachedNode = attachment + ? applyLeanToRoofAttachment(wallPlacement, attachment) + : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), event.node) + 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)) + 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/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 2045e41482..ecd048cf04 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -167,6 +167,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,6 +194,7 @@ function roofSegmentPitchHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: (n) => n.wallHeight, + gridSnap: true, currentValue: (n) => getPeakHeight(n), apply: (initial, newPeakHeight) => { const roofHeight = Math.max(0, newPeakHeight - initial.wallHeight) diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index ed87ec213c..0619985b62 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, @@ -77,6 +78,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) => { @@ -205,6 +213,19 @@ 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) @@ -249,6 +270,14 @@ export default function RoofSegmentPanel() { )} + + + + ({ - 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/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index 5eac7859e3..0292cb708c 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 0eb259d4ad..149bb8200a 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -34,7 +34,7 @@ import { wallSlots } from './slots' export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', - schemaVersion: 7, + schemaVersion: 8, schema: WallNode, category: 'structure', surfaceRole: 'wall', @@ -46,7 +46,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' }), @@ -90,7 +96,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', @@ -154,7 +160,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/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/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.