diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 5ba049ddbe..f17a293020 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,6 +1,6 @@ 'use client' -import { nodeRegistry } from '@pascal-app/core' +import { nodeRegistry, useRegistryVersion } from '@pascal-app/core' import { type FloorplanMode, getFloorplanNodeExtension, @@ -13,13 +13,14 @@ import { } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' -import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/toolbar-tooltip' +import { getActiveRoofFeatureId } from '@/lib/build-tab-state' import { cn } from '@/lib/utils' /** @@ -169,10 +170,36 @@ function activateTerrainSculptMode(): void { useEditor.getState().setMode('terrain-sculpt') } -type RoofFeature = { kind: string; label: string; iconSrc: string } +type RoofFeature = { + id: string + label: string + iconSrc: string + kind?: string +} const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp' +function collectRoofFeatures(): RoofFeature[] { + const features: RoofFeature[] = [] + for (const [kind, def] of nodeRegistry.entries()) { + if ( + def.capabilities.roofAccessory === undefined && + def.presentation?.paletteGroup !== 'roof-features' + ) { + continue + } + if (def.capabilities.wallOpeningPlacement) continue + const icon = def.presentation?.icon + features.push({ + id: kind, + kind, + label: def.presentation?.label ?? kind, + iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, + }) + } + return features +} + /** * Roof accessories and extensions surfaced under the Roof tile. Unlike the * community editor these aren't DB presets — each is a registry kind, either @@ -181,13 +208,13 @@ const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp' * populated during app bootstrap. Label + icon come from `presentation`; * non-url icons fall back to the roof icon. */ -function activateRoofFeatureTool(kind: string): void { +function activateRoofFeatureTool(feature: RoofFeature): void { const ed = useEditor.getState() ed.setPhase('structure') ed.setStructureLayer('elements') ed.setCatalogCategory(null) ed.setMode('build') - ed.setTool(kind) + if (feature.kind) ed.setTool(feature.kind) } /** @@ -211,15 +238,13 @@ export function BuildTab() { const floorplanMode = useFloorplanMode((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) + useRegistryVersion() const registryReady = useSyncExternalStore( subscribeToClientMount, () => true, () => false, ) - const buildTypes = useMemo( - () => (registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES), - [floorplanMode, registryReady], - ) + const buildTypes = registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES // 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 @@ -242,29 +267,7 @@ 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 && - 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. - if (def.capabilities.wallOpeningPlacement) continue - const icon = def.presentation?.icon - features.push({ - kind, - label: def.presentation?.label ?? kind, - iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, - }) - } - return features - }, [registryReady]) + const roofFeatures = registryReady ? collectRoofFeatures() : [] // Tile highlight derives from the single source of truth (the active tool / // mode), never a separate local selection — so keyboard shortcuts and panel @@ -272,8 +275,8 @@ export function BuildTab() { // The roof Features sub-grid arms roof-accessory tools (skylight, chimney, // …); keep the Roof tile lit (and its panel open) while any of them is the // active tool, the same way MEP stays lit for its sub-grid tools. - const isRoofFeatureActive = - mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool) + const activeRoofFeatureId = getActiveRoofFeatureId(roofFeatures, activeTool) + const isRoofFeatureActive = mode === 'build' && activeRoofFeatureId !== null const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool) const isTypeActive = (type: BuildType) => { @@ -377,11 +380,12 @@ export function BuildTab() { style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }} > {roofFeatures.map((feature) => { - const active = mode === 'build' && activeTool === feature.kind + const active = mode === 'build' && feature.id === activeRoofFeatureId return ( - + + + + + ))} + + ) : ( +
No windows
+ )} + +
+ } + label="Add Window" + onClick={onAdd} + /> + {!canAdd && ( +

+ Increase the dormer width to add another window. +

+ )} +
+ + ) +} diff --git a/packages/nodes/src/dormer/panel.tsx b/packages/nodes/src/dormer/panel.tsx index dd19c16dfa..11c3fad1e3 100644 --- a/packages/nodes/src/dormer/panel.tsx +++ b/packages/nodes/src/dormer/panel.tsx @@ -3,14 +3,18 @@ import { type AnyNode, type AnyNodeId, + createDormerDefaultWindow, type DormerNode, + generateId, type RoofNode, type RoofSegmentNode, useLiveNodeOverrides, useScene, + WindowNode, } from '@pascal-app/core' import { cn, + createFreshPlacementSubtree, PanelSection, PanelWrapper, SliderControl, @@ -19,11 +23,14 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { DormerActionsSection } from './panel-actions-section' import { DormerPositionSection } from './panel-position-section' -import { DormerWindowSection } from './panel-window-section' +import { DormerWindowsSection } from './panel-windows-section' +import { planDormerWindowRow } from './window-layout' type RoofType = DormerNode['roofType'] +type ShedHighSide = DormerNode['shedHighSide'] type DormerSection = 'dormer' | 'window' const ROOF_TYPE_OPTIONS: Array<{ label: string; value: RoofType }> = [ @@ -36,9 +43,14 @@ const ROOF_TYPE_OPTIONS: Array<{ label: string; value: RoofType }> = [ { label: 'Flat', value: 'flat' }, ] +const SHED_HIGH_SIDE_OPTIONS: Array<{ label: string; value: ShedHighSide }> = [ + { label: 'Rise Back', value: 'back' }, + { label: 'Rise Front', value: 'front' }, +] + const SECTION_OPTIONS: Array<{ label: string; value: DormerSection }> = [ { label: 'Dormer', value: 'dormer' }, - { label: 'Window', value: 'window' }, + { label: 'Windows', value: 'window' }, ] export default function DormerPanel() { @@ -56,6 +68,16 @@ export default function DormerPanel() { selectedId ? (s.get(selectedId as AnyNodeId) as Partial | undefined) : undefined, ) const node = storeNode && overrides ? ({ ...storeNode, ...overrides } as DormerNode) : storeNode + const hostedWindows = useScene( + useShallow((state) => { + if (!selectedId) return [] + const dormer = state.nodes[selectedId as AnyNodeId] + if (dormer?.type !== 'dormer') return [] + return (dormer.children ?? []) + .map((childId) => state.nodes[childId as AnyNodeId]) + .filter((child): child is WindowNode => child?.type === 'window') + }), + ) const handleUpdate = useCallback( (updates: Partial) => { @@ -109,19 +131,14 @@ export default function DormerPanel() { const handleDuplicate = useCallback(() => { if (!node?.roofSegmentId) return triggerSFX('sfx:item-pick') - // Deep clone and strip the id so the move tool's onClick branch - // (`isNew || !node.id`) takes the "create fresh" path. Setting - // `metadata.isNew = true` is what gates the move tool from - // updating any existing node — the dormer is only added to the - // scene on click, not when the Duplicate button is pressed. - const cloned = structuredClone(node) as DormerNode & { id?: AnyNodeId } - delete (cloned as { id?: AnyNodeId }).id - const prevMeta = - cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata) - ? (cloned.metadata as Record) - : {} - cloned.metadata = { ...prevMeta, isNew: true } - setMovingNode(cloned as DormerNode) + useScene.temporal.getState().pause() + const draftId = createFreshPlacementSubtree(node.id as AnyNodeId) + const draft = draftId ? (useScene.getState().nodes[draftId] as DormerNode | undefined) : null + if (!draft) { + useScene.temporal.getState().resume() + return + } + setMovingNode(draft) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) @@ -147,6 +164,69 @@ export default function DormerPanel() { } }, [selectedId, node, deleteNode, setSelection]) + const handleAddWindow = useCallback(() => { + if (!node) return + const frontWindows = hostedWindows.filter( + (window) => (window.dormerFace ?? 'front') === 'front', + ) + const template = frontWindows[0] ?? hostedWindows[0] + const id = generateId('window') + const defaultWindow = createDormerDefaultWindow(node, id) + const newWindow = WindowNode.parse({ + ...(template ? structuredClone(template) : defaultWindow), + id, + name: `Window ${hostedWindows.length + 1}`, + parentId: node.id, + dormerId: node.id, + dormerFace: 'front', + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + position: [0, template?.position[1] ?? defaultWindow.position[1], 0], + rotation: [0, 0, 0], + side: 'front', + metadata: {}, + visible: true, + }) + const plan = planDormerWindowRow(node.width, [...frontWindows, newWindow]) + if (!plan) return + + const newPlacement = plan.find((entry) => entry.id === newWindow.id) + if (!newPlacement) return + const placedWindow = WindowNode.parse({ + ...newWindow, + position: newPlacement.position, + width: newPlacement.width, + }) + const existingIds = new Set(frontWindows.map((window) => window.id)) + useScene.getState().applyNodeChanges({ + create: [{ node: placedWindow, parentId: node.id as AnyNodeId }], + update: plan + .filter((entry) => existingIds.has(entry.id)) + .map((entry) => ({ + id: entry.id as AnyNodeId, + data: { position: entry.position, width: entry.width }, + })), + }) + triggerSFX('sfx:structure-build') + }, [hostedWindows, node]) + + const handleEditWindow = useCallback( + (window: WindowNode) => { + setSelection({ selectedIds: [window.id] }) + }, + [setSelection], + ) + + const handleMoveWindow = useCallback( + (window: WindowNode) => { + triggerSFX('sfx:item-pick') + setMovingNode(window) + setSelection({ selectedIds: [] }) + }, + [setMovingNode, setSelection], + ) + if (!(node && node.type === 'dormer' && selectedId)) return null const scenestate = useScene.getState() @@ -156,6 +236,18 @@ export default function DormerPanel() { const roof = segment?.parentId ? (scenestate.nodes[segment.parentId as AnyNodeId] as RoofNode | undefined) : undefined + const frontWindows = hostedWindows.filter((window) => (window.dormerFace ?? 'front') === 'front') + const templateWindow = frontWindows[0] ?? hostedWindows[0] + const defaultWindow = createDormerDefaultWindow(node, 'window_preview') + const canAddWindow = + planDormerWindowRow(node.width, [ + ...frontWindows, + { + id: 'window_preview', + position: [0, templateWindow?.position[1] ?? defaultWindow.position[1], 0], + width: templateWindow?.width ?? defaultWindow.width, + }, + ]) !== null return ( previewProp({ roofHeight: v })} @@ -272,15 +364,41 @@ export default function DormerPanel() { })} + + {node.roofType === 'shed' && ( + +
+ {SHED_HIGH_SIDE_OPTIONS.map((option) => { + const isSelected = node.shedHighSide === option.value + return ( + + ) + })} +
+
+ )} )} {section === 'window' && ( - )} diff --git a/packages/nodes/src/dormer/parametrics.ts b/packages/nodes/src/dormer/parametrics.ts index d8a46cccf1..f9fe43aa18 100644 --- a/packages/nodes/src/dormer/parametrics.ts +++ b/packages/nodes/src/dormer/parametrics.ts @@ -1,5 +1,4 @@ import type { ParametricDescriptor } from '@pascal-app/core' -import { dormerSupportsArch } from './geometry' import type { DormerNode } from './schema' export const dormerParametrics: ParametricDescriptor = { @@ -26,88 +25,18 @@ export const dormerParametrics: ParametricDescriptor = { display: 'select', }, { key: 'roofHeight', kind: 'number', unit: 'm', min: 0, max: 2, step: 0.05 }, - ], - }, - { - label: 'Hung wall', - fields: [{ key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 }], - }, - { - label: 'Window opening', - fields: [ - { key: 'windowWidth', kind: 'number', unit: 'm', min: 0.2, max: 3, step: 0.05 }, - { key: 'windowHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 }, - { key: 'windowOffsetX', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.05 }, - { key: 'windowOffsetY', kind: 'number', unit: 'm', min: 0, max: 2, step: 0.05 }, - ], - }, - { - label: 'Window grid', - fields: [ - { key: 'windowColumns', kind: 'number', min: 1, max: 8, step: 1 }, - { key: 'windowRows', kind: 'number', min: 1, max: 8, step: 1 }, - ], - }, - { - label: 'Window frame', - fields: [ - { - key: 'windowFrameThickness', - kind: 'number', - unit: 'm', - min: 0.01, - max: 0.15, - step: 0.005, - }, - { key: 'windowFrameDepth', kind: 'number', unit: 'm', min: 0.02, max: 0.15, step: 0.005 }, - { - key: 'windowDividerThickness', - kind: 'number', - unit: 'm', - min: 0, - max: 0.06, - step: 0.002, - }, { - key: 'windowShape', + key: 'shedHighSide', kind: 'enum', - options: ['rectangle', 'rounded', 'arch'], + options: ['back', 'front'], display: 'segmented', - }, - { - key: 'windowArchHeight', - kind: 'number', - unit: 'm', - min: 0.1, - max: 1, - step: 0.05, - visibleIf: dormerSupportsArch, + visibleIf: (n) => n.roofType === 'shed', }, ], }, { - label: 'Sill', - fields: [ - { key: 'windowSill', kind: 'boolean' }, - { - key: 'windowSillDepth', - kind: 'number', - unit: 'm', - min: 0.02, - max: 0.3, - step: 0.01, - visibleIf: (n) => n.windowSill === true, - }, - { - key: 'windowSillThickness', - kind: 'number', - unit: 'm', - min: 0.01, - max: 0.1, - step: 0.005, - visibleIf: (n) => n.windowSill === true, - }, - ], + label: 'Hung wall', + fields: [{ key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 }], }, ], } diff --git a/packages/nodes/src/dormer/preview.tsx b/packages/nodes/src/dormer/preview.tsx index 17932cd7ee..e54490c443 100644 --- a/packages/nodes/src/dormer/preview.tsx +++ b/packages/nodes/src/dormer/preview.tsx @@ -29,7 +29,15 @@ const DormerPreview = ({ node, invalid }: { node: DormerNode; invalid?: boolean // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geo = useMemo( () => buildDormerGhostGeometry(node), - [node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight], + [ + node.width, + node.depth, + node.height, + node.roofHeight, + node.roofType, + node.shedHighSide, + node.wallSkirtHeight, + ], ) useEffect(() => () => geo.dispose(), [geo]) diff --git a/packages/nodes/src/dormer/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index 8199ec80db..0fabf50211 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -1,31 +1,36 @@ 'use client' import { + type AnyNode, type AnyNodeId, type DormerNode, + type DormerWallFace, + getDormerWallFaceFrame, getEffectiveDormerSurfaceMaterial, type RoofSegmentNode, useLiveNodeOverrides, useRegistry, useScene, + type WindowNode, } from '@pascal-app/core' import { type ColorPreset, createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + NodeRenderer, useNodeEvents, useViewer, } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef } from 'react' +import { type ReactNode, useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { useShallow } from 'zustand/react/shallow' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildDormerFallbackGeometry, DORMER_GABLE_MATERIAL_INDEX, generateDormerGeometry, } from './csg-geometry' -import DormerWindowAssembly from './window-assembly' const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { const ref = useRef(null!) @@ -39,12 +44,34 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { // 32-segment arch curves). Commit clears the override and the real // CSG mesh kicks back in. const liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id as AnyNodeId)) + const liveWindowOverrides = useLiveNodeOverrides((state) => state.overrides) const isLiveDrag = !!liveOverrides && Object.keys(liveOverrides).length > 0 const node = useMemo( () => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as DormerNode) : storeNode), [storeNode, liveOverrides], ) + const childNodes = useScene( + useShallow((state) => + (node.children ?? []) + .map((childId) => state.nodes[childId as AnyNodeId]) + .filter((child): child is AnyNode => child !== undefined), + ), + ) + const hostedWindows = useMemo( + () => + childNodes + .filter((child): child is WindowNode => child.type === 'window') + .map((window) => { + const override = liveWindowOverrides.get(window.id) + return override ? ({ ...window, ...override } as WindowNode) : window + }), + [childNodes, liveWindowOverrides], + ) + const hasLiveWindowPreview = childNodes.some( + (child) => child.type === 'window' && liveWindowOverrides.has(child.id), + ) + const segment = useScene((state) => node.roofSegmentId ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) @@ -99,30 +126,18 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { node.wallMaterialPreset, ]) - // The window frame bars / sill take the 'joinery' role when untextured; - // otherwise the deck-side material (slot 1) drives the frame look. - const frameSideMat = useMemo(() => { - if (!textures) return createSurfaceRoleMaterial('joinery', colorPreset, undefined, sceneTheme) - return material[1]! - }, [textures, colorPreset, sceneTheme, material]) - - // Dormer window glass has no per-node material — it always takes the - // themed 'glazing' role (semi-transparent) in both texture modes. - const glassMat = useMemo( - () => createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme), - [colorPreset, sceneTheme], - ) - // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo(() => { if (!segment) return null - if (isLiveDrag) return buildDormerFallbackGeometry(node) - return generateDormerGeometry(node, segment) + if (isLiveDrag || hasLiveWindowPreview) return buildDormerFallbackGeometry(node) + return generateDormerGeometry(node, segment, hostedWindows) }, [ isLiveDrag, + hasLiveWindowPreview, segment, node.id, node.roofType, + node.shedHighSide, node.width, node.depth, node.height, @@ -132,16 +147,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { node.position[1], node.position[2], node.rotation, - node.windowWidth, - node.windowHeight, - node.windowOffsetX, - node.windowOffsetY, - node.windowShape, - node.windowArchHeight, - node.windowCornerRadii[0], - node.windowCornerRadii[1], - node.windowCornerRadii[2], - node.windowCornerRadii[3], + hostedWindows, ]) useEffect(() => () => geometry?.dispose(), [geometry]) @@ -174,12 +180,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { // local frame is *dormer-local* — that's what `NodeArrowHandles` // reads to place its chevrons. Mirrors chimney's structure. return ( - + { material={material} name="dormer-body" receiveShadow + {...handlers} /> - + {hostedWindows.map((window) => ( + + + + ))} ) } +function DormerWindowHostFrame({ + dormer, + face, + children, +}: { + dormer: DormerNode + face: DormerWallFace + children: ReactNode +}) { + const frame = getDormerWallFaceFrame(dormer, face) + return ( + + {children} + + ) +} + // Re-export so consumers (e.g. tests) can reach the gable slot index // without importing from `@pascal-app/viewer` directly. export { DORMER_GABLE_MATERIAL_INDEX } diff --git a/packages/nodes/src/dormer/tool.tsx b/packages/nodes/src/dormer/tool.tsx index e649ebb9e8..00bce365eb 100644 --- a/packages/nodes/src/dormer/tool.tsx +++ b/packages/nodes/src/dormer/tool.tsx @@ -1,6 +1,12 @@ 'use client' -import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + createDormerDefaultWindow, + DormerNode, + getDormerDefaultWindowFace, + useScene, +} from '@pascal-app/core' import { usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo } from 'react' @@ -66,6 +72,12 @@ const DormerTool = () => { rotation, }) state.createNode(dormer, hit.segment.id as AnyNodeId) + const defaultWindow = createDormerDefaultWindow( + dormer, + `window_${dormer.id.replace(/^dormer_/, '')}_default`, + getDormerDefaultWindowFace(dormer, hit.segment), + ) + state.createNode(defaultWindow, dormer.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [dormer.id] }) usePlacementPreview.getState().clear() diff --git a/packages/nodes/src/dormer/window-assembly.tsx b/packages/nodes/src/dormer/window-assembly.tsx deleted file mode 100644 index f511fc33d5..0000000000 --- a/packages/nodes/src/dormer/window-assembly.tsx +++ /dev/null @@ -1,210 +0,0 @@ -'use client' - -import type { DormerNode, RoofSegmentNode } from '@pascal-app/core' -import { useEffect, useMemo } from 'react' -import * as THREE from 'three' -import { TrimClippedMesh } from '../shared/use-segment-trim-clip' -import { getDormerExposedFaces, getDormerSkirtWindowDims } from './csg-geometry' -import { buildDormerWindowGeometries, type DormerWindowShape } from './window-frame' - -/** - * Renders the window opening assembly (frame bars, glass panes, sill) - * on each exposed gable face of a dormer. Owns its geometry lifecycle - * (build via `buildDormerWindowGeometries`, dispose on unmount) so the - * renderer doesn't have to. - * - * Mounted inside the dormer's rotation group, in dormer-mesh-local - * coordinates. The CSG cut on the wall is performed separately inside - * the viewer's `generateDormerGeometry`; the geometry built here is - * sized to match that cut. - */ -const DormerWindowAssembly = ({ - node, - segment, - frameMaterial, - glassMaterial, - dormerToSegment, -}: { - node: DormerNode - segment: RoofSegmentNode - frameMaterial: THREE.Material - glassMaterial: THREE.Material - // Maps dormer-mesh-local space into the host segment-local frame (where the - // trim cut prisms live). Threaded from the renderer so the window glass / - // frame / sill slice at the trim plane like the dormer body. - dormerToSegment: THREE.Matrix4 -}) => { - // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. - const skirtWin = useMemo( - () => getDormerSkirtWindowDims(node), - [ - node.width, - node.windowWidth, - node.windowHeight, - node.windowOffsetX, - node.windowOffsetY, - node.wallSkirtHeight, - ], - ) - - const winW = skirtWin.width - const winH = skirtWin.height - const winShape: DormerWindowShape = node.windowShape - const resolvedRadii: [number, number, number, number] = [...node.windowCornerRadii] - - // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. - const winGeo = useMemo( - () => - buildDormerWindowGeometries( - winW, - winH, - node.windowFrameThickness, - node.windowFrameDepth, - node.windowColumns, - node.windowRows, - node.windowDividerThickness, - winShape, - node.windowArchHeight, - resolvedRadii, - ), - [ - winW, - winH, - node.windowFrameThickness, - node.windowFrameDepth, - node.windowColumns, - node.windowRows, - node.windowDividerThickness, - winShape, - node.windowArchHeight, - ...resolvedRadii, - ], - ) - - useEffect(() => { - return () => { - const disposed = new Set() - for (const bar of winGeo.frameBars) { - if (!disposed.has(bar.geo)) { - bar.geo.dispose() - disposed.add(bar.geo) - } - } - for (const pane of winGeo.glassPanes) { - if (!disposed.has(pane.geo)) { - pane.geo.dispose() - disposed.add(pane.geo) - } - } - } - }, [winGeo]) - - const sillEnabled = node.windowSill !== false - const sillT = Math.max(0.001, node.windowSillThickness) - const sillD = Math.max(0.001, node.windowSillDepth) - const sillW = winW + 0.06 // 3 cm overhang each side - const sillGeo = useMemo( - () => (sillEnabled ? new THREE.BoxGeometry(sillW, sillT, sillD) : null), - [sillEnabled, sillW, sillT, sillD], - ) - useEffect(() => () => sillGeo?.dispose(), [sillGeo]) - - // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. - const exposed = useMemo( - () => getDormerExposedFaces(node, segment), - [ - segment, - node.roofType, - node.width, - node.depth, - node.height, - node.roofHeight, - node.position[0], - node.position[1], - node.position[2], - // Rotation flips which dormer-local face projects to which Z in - // segment frame, so dragging the dormer across the ridge with a - // non-zero yaw needs to recompute exposure to know which gable - // is now poking above the slope. - node.rotation, - // The window's vertical placement feeds `getDormerExposedFaces` - // (gates on the window CENTER clearing the host slope) — dragging - // the window down via inspector or the offset handle must - // re-evaluate which gable still exposes the opening. - node.windowOffsetY, - node.wallSkirtHeight, - ], - ) - - const gableHalfZ = node.depth / 2 - const winX = skirtWin.offsetX - const winY = skirtWin.centerY - - // The glazing role material is FrontSide (DoubleSide on a NodeMaterial - // poisons the MRT scene pass — see `createSurfaceRoleMaterial`). The - // back gable face therefore renders inside a Y-rotated group so its - // FrontSide points outward (-Z in segment frame). With the rotation, - // the sill always extrudes along the group's local +Z, so its position - // no longer needs to flip per-face. - const renderFace = (zPos: number, yRot: number, keyPrefix: string) => { - // Compose this face group's transform onto the dormer→segment matrix, so - // each window part can be clipped by the trim in segment-local space. - const faceToSegment = new THREE.Matrix4() - .copy(dormerToSegment) - .multiply( - new THREE.Matrix4().compose( - new THREE.Vector3(winX, winY, zPos), - new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yRot), - new THREE.Vector3(1, 1, 1), - ), - ) - return ( - - {winGeo.glassPanes.map((pane, i) => ( - - ))} - {winGeo.frameBars.map((bar, i) => ( - - ))} - {sillGeo && ( - - )} - - ) - } - - return ( - <> - {exposed.front && renderFace(gableHalfZ, 0, 'front')} - {exposed.back && renderFace(-gableHalfZ, Math.PI, 'back')} - - ) -} - -export default DormerWindowAssembly diff --git a/packages/nodes/src/dormer/window-frame.ts b/packages/nodes/src/dormer/window-frame.ts deleted file mode 100644 index 2850ba8b04..0000000000 --- a/packages/nodes/src/dormer/window-frame.ts +++ /dev/null @@ -1,160 +0,0 @@ -import * as THREE from 'three' -import { createDormerArchShape, createDormerRoundedShape } from './csg-geometry' - -/** - * Frame + glass geometry for the window opening on a dormer's gable - * face. The extruded frame profile uses the same shape builders as the - * CSG cut in the viewer (`generateDormerGeometry`), so the frame sits - * flush in the wall — keeping the cut and the frame visually in sync. - * - * Only the frame bars and glass panes are produced here; the wall - * opening itself is CSG-subtracted from the dormer body inside the - * viewer's `generateDormerGeometry`. - */ -export type DormerWindowShape = 'rectangle' | 'rounded' | 'arch' - -export type WindowGeometries = { - frameBars: { geo: THREE.BufferGeometry; pos: [number, number, number] }[] - glassPanes: { geo: THREE.BufferGeometry; pos: [number, number, number] }[] -} - -export function buildDormerWindowGeometries( - winW: number, - winH: number, - ft: number, - fd: number, - cols: number, - rows: number, - dt: number, - shape: DormerWindowShape = 'rectangle', - archHeight = 0.35, - cornerRadii: [number, number, number, number] = [0.15, 0.15, 0.15, 0.15], -): WindowGeometries { - const safeFt = Math.max(0.001, ft) - const safeDt = Math.max(0.001, dt) - const innerW = Math.max(0.01, winW - 2 * safeFt) - const innerH = Math.max(0.01, winH - 2 * safeFt) - const hw = winW / 2 - const hh = winH / 2 - - const frameBars: WindowGeometries['frameBars'] = [] - const glassPanes: WindowGeometries['glassPanes'] = [] - - if (shape === 'arch' || shape === 'rounded') { - const insetRadii = cornerRadii.map((r) => Math.max(r - safeFt, 0)) as [ - number, - number, - number, - number, - ] - const outerShape = - shape === 'arch' - ? createDormerArchShape(winW, winH, archHeight) - : createDormerRoundedShape(winW, winH, cornerRadii) - - const innerHole = - shape === 'arch' - ? createDormerArchShape( - winW - 2 * safeFt, - winH - 2 * safeFt, - Math.max(archHeight - safeFt, 0.01), - ) - : createDormerRoundedShape(winW - 2 * safeFt, winH - 2 * safeFt, insetRadii) - - outerShape.holes.push(innerHole) - const frameGeo = new THREE.ExtrudeGeometry(outerShape, { - depth: fd, - bevelEnabled: false, - curveSegments: 24, - }) - frameGeo.translate(0, 0, -fd / 2) - frameBars.push({ geo: frameGeo, pos: [0, 0, 0] }) - - const colDividerCount = cols - 1 - const totalColDividerW = colDividerCount * safeDt - const paneAreaW = Math.max(0.01, innerW - totalColDividerW) - const paneW = paneAreaW / cols - - for (let c = 1; c < cols; c++) { - const x = -innerW / 2 + c * paneW + (c - 0.5) * safeDt - frameBars.push({ geo: new THREE.BoxGeometry(safeDt, innerH, fd), pos: [x, 0, 0] }) - } - - const rowDividerCount = rows - 1 - const totalRowDividerH = rowDividerCount * safeDt - const paneAreaH = Math.max(0.01, innerH - totalRowDividerH) - const paneH = paneAreaH / rows - - for (let r = 1; r < rows; r++) { - const y = -innerH / 2 + r * paneH + (r - 0.5) * safeDt - frameBars.push({ geo: new THREE.BoxGeometry(innerW, safeDt, fd), pos: [0, y, 0] }) - } - - const glassShape = - shape === 'arch' - ? createDormerArchShape( - winW - 2 * safeFt, - winH - 2 * safeFt, - Math.max(archHeight - safeFt, 0.01), - ) - : createDormerRoundedShape(winW - 2 * safeFt, winH - 2 * safeFt, insetRadii) - const glassGeo = new THREE.ExtrudeGeometry(glassShape, { - depth: 0.008, - bevelEnabled: false, - curveSegments: 24, - }) - glassGeo.translate(0, 0, -0.004) - glassPanes.push({ geo: glassGeo, pos: [0, 0, 0] }) - } else { - frameBars.push({ - geo: new THREE.BoxGeometry(winW, safeFt, fd), - pos: [0, hh - safeFt / 2, 0], - }) - frameBars.push({ - geo: new THREE.BoxGeometry(winW, safeFt, fd), - pos: [0, -hh + safeFt / 2, 0], - }) - frameBars.push({ - geo: new THREE.BoxGeometry(safeFt, innerH, fd), - pos: [-hw + safeFt / 2, 0, 0], - }) - frameBars.push({ - geo: new THREE.BoxGeometry(safeFt, innerH, fd), - pos: [hw - safeFt / 2, 0, 0], - }) - - const colDividerCount = cols - 1 - const totalColDividerW = colDividerCount * safeDt - const paneAreaW = Math.max(0.01, innerW - totalColDividerW) - const paneW = paneAreaW / cols - - for (let c = 1; c < cols; c++) { - const x = -innerW / 2 + c * paneW + (c - 0.5) * safeDt - frameBars.push({ geo: new THREE.BoxGeometry(safeDt, innerH, fd), pos: [x, 0, 0] }) - } - - const rowDividerCount = rows - 1 - const totalRowDividerH = rowDividerCount * safeDt - const paneAreaH = Math.max(0.01, innerH - totalRowDividerH) - const paneH = paneAreaH / rows - - for (let r = 1; r < rows; r++) { - const y = -innerH / 2 + r * paneH + (r - 0.5) * safeDt - frameBars.push({ geo: new THREE.BoxGeometry(innerW, safeDt, fd), pos: [0, y, 0] }) - } - - const glassW = Math.max(0.01, paneAreaW / cols) - const glassH = Math.max(0.01, paneAreaH / rows) - const glassGeo = new THREE.BoxGeometry(glassW, glassH, 0.008) - - for (let c = 0; c < cols; c++) { - const cx = -innerW / 2 + paneAreaW / cols / 2 + c * (paneAreaW / cols + safeDt) - for (let r = 0; r < rows; r++) { - const cy = -innerH / 2 + paneAreaH / rows / 2 + r * (paneAreaH / rows + safeDt) - glassPanes.push({ geo: glassGeo, pos: [cx, cy, 0] }) - } - } - } - - return { frameBars, glassPanes } -} diff --git a/packages/nodes/src/dormer/window-layout.test.ts b/packages/nodes/src/dormer/window-layout.test.ts new file mode 100644 index 0000000000..db7562de57 --- /dev/null +++ b/packages/nodes/src/dormer/window-layout.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test' +import { planDormerWindowRow } from './window-layout' + +describe('planDormerWindowRow', () => { + test('centres newly added windows next to each other', () => { + const plan = planDormerWindowRow(2.4, [ + { id: 'window_1', position: [0, -0.8, 0], width: 0.8 }, + { id: 'window_2', position: [0, -0.8, 0], width: 0.8 }, + ]) + + expect(plan).not.toBeNull() + expect(plan?.map((entry) => entry.position)).toEqual([ + [-0.46, -0.8, 0], + [0.46, -0.8, 0], + ]) + expect(plan?.map((entry) => entry.width)).toEqual([0.8, 0.8]) + }) + + test('shrinks the row proportionally when preferred widths do not fit', () => { + const plan = planDormerWindowRow(2.4, [ + { id: 'window_1', position: [0, -0.8, 0], width: 0.8 }, + { id: 'window_2', position: [0, -0.8, 0], width: 0.8 }, + { id: 'window_3', position: [0, -0.8, 0], width: 0.8 }, + ]) + + expect(plan).not.toBeNull() + expect(plan?.map((entry) => entry.width)).toEqual([0.64, 0.64, 0.64]) + expect(plan?.map((entry) => entry.position[0])).toEqual([-0.76, 0, 0.76]) + }) + + test('rejects a row when minimum-width windows cannot fit', () => { + const plan = planDormerWindowRow( + 1.2, + Array.from({ length: 4 }, (_, index) => ({ + id: `window_${index + 1}`, + position: [0, -0.8, 0] as [number, number, number], + width: 0.3, + })), + ) + + expect(plan).toBeNull() + }) +}) diff --git a/packages/nodes/src/dormer/window-layout.ts b/packages/nodes/src/dormer/window-layout.ts new file mode 100644 index 0000000000..8d5ce2832a --- /dev/null +++ b/packages/nodes/src/dormer/window-layout.ts @@ -0,0 +1,86 @@ +export const DORMER_WINDOW_GAP = 0.12 +export const DORMER_WINDOW_MARGIN = 0.12 +export const DORMER_WINDOW_MIN_WIDTH = 0.3 + +export type DormerWindowRowItem = { + id: string + position: readonly [number, number, number] + width: number +} + +export type DormerWindowRowPlacement = { + id: string + position: [number, number, number] + width: number +} + +const roundLayoutValue = (value: number) => { + const rounded = Math.round(value * 1_000_000) / 1_000_000 + return Object.is(rounded, -0) ? 0 : rounded +} + +function fitWindowWidths(preferredWidths: number[], availableWidth: number): number[] | null { + const minimumTotal = preferredWidths.length * DORMER_WINDOW_MIN_WIDTH + if (availableWidth + 1e-9 < minimumTotal) return null + + const widths = preferredWidths.map((width) => Math.max(DORMER_WINDOW_MIN_WIDTH, width)) + if (widths.reduce((sum, width) => sum + width, 0) <= availableWidth) return widths + + const fitted = Array.from({ length: widths.length }, () => 0) + const remainingIndices = new Set(widths.map((_, index) => index)) + let remainingWidth = availableWidth + + while (remainingIndices.size > 0) { + const preferredTotal = [...remainingIndices].reduce((sum, index) => sum + widths[index]!, 0) + const scale = remainingWidth / preferredTotal + const belowMinimum = [...remainingIndices].filter( + (index) => widths[index]! * scale < DORMER_WINDOW_MIN_WIDTH, + ) + + if (belowMinimum.length === 0) { + for (const index of remainingIndices) fitted[index] = widths[index]! * scale + break + } + + for (const index of belowMinimum) { + fitted[index] = DORMER_WINDOW_MIN_WIDTH + remainingWidth -= DORMER_WINDOW_MIN_WIDTH + remainingIndices.delete(index) + } + } + + return fitted +} + +export function planDormerWindowRow( + dormerWidth: number, + windows: readonly DormerWindowRowItem[], +): DormerWindowRowPlacement[] | null { + if (windows.length === 0) return [] + + const innerWidth = Math.max(0, dormerWidth - DORMER_WINDOW_MARGIN * 2) + const gapsWidth = DORMER_WINDOW_GAP * Math.max(0, windows.length - 1) + const widths = fitWindowWidths( + windows.map((window) => window.width), + innerWidth - gapsWidth, + ) + if (!widths) return null + + const rowWidth = widths.reduce((sum, width) => sum + width, 0) + gapsWidth + let cursor = -rowWidth / 2 + + return windows.map((window, index) => { + const width = widths[index]! + const x = cursor + width / 2 + cursor += width + DORMER_WINDOW_GAP + return { + id: window.id, + position: [ + roundLayoutValue(x), + roundLayoutValue(window.position[1]), + roundLayoutValue(window.position[2]), + ], + width: roundLayoutValue(width), + } + }) +} diff --git a/packages/nodes/src/lean-to-extension/assembly.test.ts b/packages/nodes/src/lean-to-extension/assembly.test.ts index 1ec83c15b7..00d4d04ee5 100644 --- a/packages/nodes/src/lean-to-extension/assembly.test.ts +++ b/packages/nodes/src/lean-to-extension/assembly.test.ts @@ -606,6 +606,90 @@ describe('lean-to assembly', () => { ) }) + test('extends freestanding canopy posts from an upper level down to site ground', () => { + const building = BuildingNode.parse({ + id: 'building_freestanding_post', + children: ['level_freestanding_lower', 'level_freestanding_upper'], + }) + const lower = LevelNode.parse({ + id: 'level_freestanding_lower', + parentId: building.id, + level: 0, + height: 3, + }) + const upper = LevelNode.parse({ + id: 'level_freestanding_upper', + parentId: building.id, + level: 1, + height: 3, + }) + const leanTo = LeanToExtensionNode.parse({ + parentId: upper.id, + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + position: [0, 0, 0], + }) + const nodes = { + [building.id]: building, + [lower.id]: lower, + [upper.id]: upper, + [leanTo.id]: leanTo, + } as Record + + const baseY = resolveLeanToPostBaseY(leanTo, undefined, nodes, 0) + const post = leanToPostLayoutPatch(leanTo, 0, baseY) + + expect(post.position[1]).toBeCloseTo(-3.02, 6) + expect(post.position[1] + post.height).toBeCloseTo( + resolveLeanToLayout(leanTo).postHeight + 0.02, + 6, + ) + }) + + test('extends upper-storey pillars through open space to site ground', () => { + const building = BuildingNode.parse({ + id: 'building_upper_post', + children: ['level_lower_post', 'level_upper_post'], + }) + const lower = LevelNode.parse({ + id: 'level_lower_post', + parentId: building.id, + level: 0, + height: 3, + }) + const upper = LevelNode.parse({ + id: 'level_upper_post', + parentId: building.id, + level: 1, + height: 3, + children: ['wall_upper_post'], + }) + const wall = WallNode.parse({ + id: 'wall_upper_post', + parentId: upper.id, + start: [-2, 0], + end: [2, 0], + thickness: 0.1, + }) + const leanTo = LeanToExtensionNode.parse({ + parentId: wall.id, + position: [2, 0, wall.thickness / 2], + projection: 2.5, + }) + const nodes = Object.fromEntries( + [building, lower, upper, wall, leanTo].map((node) => [node.id, node]), + ) as Record + + const baseY = resolveLeanToPostBaseY(leanTo, wall, nodes, 0) + const post = leanToPostLayoutPatch(leanTo, 0, baseY) + + expect(post.position[1]).toBeCloseTo(-3.02, 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 = { diff --git a/packages/nodes/src/lean-to-extension/assembly.ts b/packages/nodes/src/lean-to-extension/assembly.ts index 674befd216..2ef3c1566d 100644 --- a/packages/nodes/src/lean-to-extension/assembly.ts +++ b/packages/nodes/src/lean-to-extension/assembly.ts @@ -8,7 +8,9 @@ import { GutterNode, type GutterNode as GutterNodeType, generateId, + getLevelElevations, getWallBaseElevationForNodes, + heightAt, type LeanToExtensionNode, levelBaseElevationAt, RoofNode, @@ -16,11 +18,13 @@ import { RoofSegmentNode, type RoofSegmentNode as RoofSegmentNodeType, spatialGridManager, + terrainFieldOf, type WallNode, } from '@pascal-app/core' import { resolveEaveSnap } from '../gutter/eave-snap' import { getRoofTopSurfaceY } from '../shared/roof-surface' import { bendLocalPoint, bendRotationYAtLocalX, isCurvedLeanTo } from './arc' +import { isClosedLoopLeanTo } from './conical-host' import { applyLeanToCornerRoofPieces, LEAN_TO_CORNER_JOINTS_KEY, @@ -30,6 +34,7 @@ import { resolveLeanToCornerJoints, } from './corner-joint' import { resolveLeanToLayout } from './layout' +import { isLeanToPostOmitted } from './post-omissions' const MANAGED_BY_KEY = 'managedByLeanTo' const MANAGED_ROLE_KEY = 'leanToRole' @@ -222,9 +227,33 @@ export function resolveLeanToPostGutterSetback( return Math.min(gutterClearanceSetback, leanTo.beamWidth / 2) } +function siteGroundYInLevelFrame( + nodes: Record, + levelId: string, + x: number, + z: number, +): number { + const elevation = getLevelElevations(nodes).get(levelId) + if (!elevation) return levelBaseElevationAt(nodes, levelId, x, z) + + const building = elevation.buildingId ? nodes[elevation.buildingId] : undefined + const buildingPosition: [number, number, number] = + building?.type === 'building' ? building.position : [0, 0, 0] + const buildingRotation = building?.type === 'building' ? building.rotation[1] : 0 + const cos = Math.cos(buildingRotation) + const sin = Math.sin(buildingRotation) + const worldX = buildingPosition[0] + x * cos + z * sin + const worldZ = buildingPosition[2] - x * sin + z * cos + const site = Object.values(nodes).find((node) => node.type === 'site') + const terrain = terrainFieldOf(site) + const groundWorldY = terrain ? heightAt(terrain, worldX, worldZ) : 0 + const levelWorldY = buildingPosition[1] + elevation.baseY + return groundWorldY - levelWorldY +} + export function resolveLeanToPostBaseY( leanTo: LeanToExtensionNode, - wall: WallNode, + wall: WallNode | undefined, nodes: Record, index: number, side: LeanToPostSide = 'low', @@ -238,11 +267,11 @@ export function resolveLeanToPostBaseY( export function resolveLeanToPostBaseYAtLocalPosition( leanTo: LeanToExtensionNode, - wall: WallNode, + wall: WallNode | undefined, nodes: Record, localPosition: readonly [number, number, number], ): number { - const levelId = wall.parentId + const levelId = wall?.parentId ?? leanTo.parentId if (!levelId || nodes[levelId]?.type !== 'level') return 0 const postX = localPosition[0] @@ -250,16 +279,25 @@ export function resolveLeanToPostBaseYAtLocalPosition( const leanCos = Math.cos(leanRotation) const leanSin = Math.sin(leanRotation) const postZ = localPosition[2] - const wallLocalX = leanTo.position[0] + postX * leanCos + postZ * leanSin - const wallLocalZ = leanTo.position[2] - postX * leanSin + postZ * leanCos - const wallAngle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) - const wallCos = Math.cos(wallAngle) - const wallSin = Math.sin(wallAngle) - const position: [number, number, number] = [ - wall.start[0] + wallLocalX * wallCos - wallLocalZ * wallSin, - 0, - wall.start[1] + wallLocalX * wallSin + wallLocalZ * wallCos, - ] + const position: [number, number, number] = wall + ? (() => { + 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) + return [ + wall.start[0] + wallLocalX * wallCos - wallLocalZ * wallSin, + 0, + wall.start[1] + wallLocalX * wallSin + wallLocalZ * wallCos, + ] + })() + : [ + leanTo.position[0] + postX * leanCos + postZ * leanSin, + 0, + leanTo.position[2] - postX * leanSin + postZ * leanCos, + ] + const wallAngle = wall ? Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) : 0 const support = spatialGridManager.getSlabSupportForItem( levelId, position, @@ -268,10 +306,13 @@ export function resolveLeanToPostBaseYAtLocalPosition( ) const groundY = support.slabId === null - ? levelBaseElevationAt(nodes, levelId, position[0], position[2]) + ? siteGroundYInLevelFrame(nodes, levelId, position[0], position[2]) : support.elevation return ( - groundY - getWallBaseElevationForNodes(wall, nodes) - leanTo.position[1] - POST_GROUND_EMBED + groundY - + (wall ? getWallBaseElevationForNodes(wall, nodes) : 0) - + leanTo.position[1] - + POST_GROUND_EMBED ) } @@ -333,11 +374,14 @@ export function resolveLeanToPostIndexes( ): number[] { const layout = resolveLeanToLayout(leanTo) return Array.from({ length: layout.postXs.length }, (_, index) => index).filter((index) => { + if (isLeanToPostOmitted(leanTo, side, index)) return false if (side === 'high') return true const x = layout.postXs[index] ?? 0 const left = cornerJoints.left + if (left?.kind === 'linear' && index === 0) return false if (left?.kind === 'concave' && x <= left.sharedPostPosition[0] + 1e-6) return false const right = cornerJoints.right + if (right?.kind === 'linear' && index === layout.postXs.length - 1) return false if (right?.kind === 'concave' && x >= right.sharedPostPosition[0] - 1e-6) return false return true }) @@ -362,6 +406,9 @@ export type LeanToRoofSegmentLayoutPatch = Pick< | 'shedSideInfillMaxX' | 'shedFootprintPieces' | 'shedOpenEndSides' + | 'managedByParent' + | 'wallShell' + | 'shedInsetEndPanels' | 'trim' | 'metadata' > @@ -411,6 +458,9 @@ export function leanToRoofSegmentLayoutPatch( polygon.map(([x = 0, z = 0]) => [x - roofCenterX, z - roofCenterZ] as [number, number]), ) const jointSides = Object.values(cornerJoints).flatMap((joint) => (joint ? [joint.side] : [])) + const hasShapedCorner = Object.values(cornerJoints).some( + (joint) => joint && joint.kind !== 'linear', + ) const sideMemberFaceInset = Math.min( Math.max(0, leanTo.rafterWidth / 2), Math.max(0, layout.span / 2 - 0.01), @@ -447,8 +497,11 @@ export function leanToRoofSegmentLayoutPatch( shedSideInfillSpan: layout.span, shedSideInfillMinX: -layout.span / 2 - sideMemberFaceInset - roofCenterX, shedSideInfillMaxX: layout.span / 2 + sideMemberFaceInset - roofCenterX, - shedFootprintPieces: jointSides.length > 0 ? roofPieces : undefined, + shedFootprintPieces: hasShapedCorner ? roofPieces : undefined, shedOpenEndSides: jointSides.length > 0 ? jointSides : undefined, + managedByParent: true, + wallShell: 'omit', + shedInsetEndPanels: true, metadata: managedMetadata(leanTo, 'roof-segment'), trim: { left: 0, @@ -486,6 +539,8 @@ export function leanToGutterLayoutPatch( | 'visible' | 'profile' | 'size' + | 'endCapLeft' + | 'endCapRight' | 'outlets' | 'metadata' > { @@ -530,6 +585,11 @@ export function leanToGutterLayoutPatch( const neighbor = nodes[joint.neighborId] return neighbor?.type === 'lean-to-extension' && neighbor.gutterEnabled ? joint.gutterMitre : 0 } + const gutterOpenAtJoint = (joint: LeanToCornerJoint | undefined): boolean => { + if (!(leanTo.gutterEnabled && joint && nodes)) return false + const neighbor = nodes[joint.neighborId] + return neighbor?.type === 'lean-to-extension' && neighbor.gutterEnabled + } const length = Math.max(0.05, segment.width + 2 * segment.overhang) const jointAwareDownspoutPosition = cornerJoints.left && leanTo.downspoutPosition < -0.75 @@ -583,6 +643,8 @@ export function leanToGutterLayoutPatch( visible: leanTo.gutterEnabled, profile: leanTo.gutterProfile, size: leanTo.gutterSize, + endCapLeft: !isClosedLoopLeanTo(leanTo) && !gutterOpenAtJoint(cornerJoints.left), + endCapRight: !isClosedLoopLeanTo(leanTo) && !gutterOpenAtJoint(cornerJoints.right), outlets: leanTo.gutterEnabled && leanTo.downspoutEnabled ? [outlet] : [], metadata: { ...metadataRecord(gutter?.metadata), @@ -702,7 +764,12 @@ export function createLeanToAssembly( createManagedLeanToPost(leanTo, index, 'low'), ) for (const joint of Object.values(cornerJoints)) { - if (joint?.sharedPostOwner) posts.push(createManagedLeanToCornerPost(leanTo, joint)) + if ( + joint?.sharedPostOwner && + !isLeanToPostOmitted(leanTo, 'low', leanToCornerPostIndex(joint.side)) + ) { + posts.push(createManagedLeanToCornerPost(leanTo, joint)) + } } if (leanTo.highSideMode === 'independent-high-beam') { posts.push( diff --git a/packages/nodes/src/lean-to-extension/conical-host.test.ts b/packages/nodes/src/lean-to-extension/conical-host.test.ts new file mode 100644 index 0000000000..304ab54608 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/conical-host.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + LevelNode, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' +import { bendLocalPoint } from './arc' +import { createLeanToAssembly } from './assembly' +import { + findConicalLeanToHostInPlan, + resolveConicalLeanToPlacement, + resolveConicalLeanToSurfaceHit, +} from './conical-host' +import { resolveLeanToLayout } from './layout' + +describe('resolveConicalLeanToPlacement', () => { + test('wraps one closed lean-to around the cylindrical base', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_conical', + parentId: 'roof_test', + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 3, + }) + + const leanTo = resolveConicalLeanToPlacement(segment) + + expect(leanTo).not.toBeNull() + expect(leanTo?.parentId).toBe(segment.id) + expect(leanTo?.hostKind).toBe('conical-roof') + expect(leanTo?.position).toEqual([0, 0, 4]) + expect(leanTo?.span).toBeCloseTo(8 * Math.PI) + expect(leanTo?.spanArcCenterZ).toBe(-4) + expect(leanTo?.spanArcRadius).toBe(4) + expect(leanTo?.highEdgeHeight).toBe(3) + expect(leanTo?.leftOverhang).toBe(0) + expect(leanTo?.rightOverhang).toBe(0) + expect(leanTo?.leftEndCondition).toBe('joined') + expect(leanTo?.rightEndCondition).toBe('joined') + }) + + test('rejects non-conical roof segments', () => { + const segment = RoofSegmentNode.parse({ roofType: 'gable' }) + + expect(resolveConicalLeanToPlacement(segment)).toBeNull() + }) + + test('keeps an edited canopy height offset when the host changes', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 3.5, + }) + + const leanTo = resolveConicalLeanToPlacement(segment, { hostHeightOffset: 0.75 }) + + expect(leanTo?.highEdgeHeight).toBe(4.25) + expect(leanTo?.hostHeightOffset).toBe(0.75) + }) + + test('closes the assembly without duplicate seam members or gutter caps', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 3, + }) + const leanTo = resolveConicalLeanToPlacement(segment)! + + const layout = resolveLeanToLayout(leanTo) + const firstPost = bendLocalPoint(leanTo, layout.postXs[0]!, layout.beamZ) + const lastPost = bendLocalPoint(leanTo, layout.postXs.at(-1)!, layout.beamZ) + const assembly = createLeanToAssembly(leanTo) + + expect(layout.postXs).toHaveLength(9) + expect(Math.hypot(firstPost.x - lastPost.x, firstPost.y - lastPost.y)).toBeGreaterThan(0.1) + expect(assembly.posts).toHaveLength(9) + expect(assembly.segment.arc).toBeDefined() + expect(assembly.gutter.arc).toBeDefined() + expect(assembly.gutter.endCapLeft).toBe(false) + expect(assembly.gutter.endCapRight).toBe(false) + }) + + test('accepts the cylindrical wall but rejects the cone surface', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 3, + }) + + expect(resolveConicalLeanToSurfaceHit(segment, [4, 1.5, 0], [1, 0, 0])).not.toBeNull() + expect(resolveConicalLeanToSurfaceHit(segment, [2, 4, 0], [0.7, 0.7, 0])).toBeNull() + }) + + test('finds the conical footprint in the active floorplan level', () => { + const level = LevelNode.parse({ id: 'level_plan_host' }) + const roof = RoofNode.parse({ + id: 'roof_plan_host', + parentId: level.id, + position: [2, 0, 3], + children: ['rseg_plan_host'], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_plan_host', + parentId: roof.id, + roofType: 'conical', + position: [1, 0, 0], + width: 8, + depth: 8, + }) + const nodes = Object.fromEntries( + [level, roof, segment].map((node) => [node.id, node]), + ) as Record + + expect(findConicalLeanToHostInPlan([6, 3], nodes, level.id)?.segment.id).toBe(segment.id) + expect(findConicalLeanToHostInPlan([20, 20], nodes, level.id)).toBeNull() + + const existing = resolveConicalLeanToPlacement(segment, { id: 'leanto_plan_host' })! + nodes[existing.id as AnyNodeId] = existing + expect(findConicalLeanToHostInPlan([6, 3], nodes, level.id)).toBeNull() + expect( + findConicalLeanToHostInPlan([6, 3], nodes, level.id, { includeOccupied: true })?.segment.id, + ).toBe(segment.id) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/conical-host.ts b/packages/nodes/src/lean-to-extension/conical-host.ts new file mode 100644 index 0000000000..c3a0dc0bbd --- /dev/null +++ b/packages/nodes/src/lean-to-extension/conical-host.ts @@ -0,0 +1,149 @@ +import { + type AnyNode, + type AnyNodeId, + findLevelAncestorId, + LeanToExtensionNode, + type RoofSegmentNode, +} from '@pascal-app/core' + +const CONICAL_WALL_HIT_TOLERANCE = 0.15 +const CONICAL_PLAN_HIT_TOLERANCE = 0.35 + +export type ConicalLeanToPlanHost = { + segment: RoofSegmentNode + center: [number, number] + rotationY: number + node: LeanToExtensionNode +} + +export function isClosedLoopLeanTo(leanTo: Pick): boolean { + return leanTo.hostKind === 'conical-roof' +} + +export function isConicalLeanToHostOccupied( + segmentId: RoofSegmentNode['id'], + nodes: Record, +): boolean { + return Object.values(nodes).some( + (node) => + node.type === 'lean-to-extension' && + node.hostKind === 'conical-roof' && + node.parentId === segmentId, + ) +} + +export function resolveConicalLeanToPlacement( + segment: RoofSegmentNode, + source: Partial = {}, +): LeanToExtensionNode | null { + if (segment.roofType !== 'conical') return null + + const radius = segment.width / 2 + const hostHeightOffset = source.hostHeightOffset ?? 0 + const highEdgeHeight = Math.max(0.8, Math.min(10, segment.wallHeight + hostHeightOffset)) + const projection = source.projection ?? LeanToExtensionNode.shape.projection.parse(undefined) + const pitch = source.pitch ?? LeanToExtensionNode.shape.pitch.parse(undefined) + const lowEdgeHeight = highEdgeHeight - projection * Math.tan((pitch * Math.PI) / 180) + + const parsed = LeanToExtensionNode.parse({ + ...source, + parentId: segment.id, + hostKind: 'conical-roof', + hostHeightOffset, + position: [0, 0, radius], + rotation: [0, 0, 0], + span: 2 * Math.PI * radius, + autoSpan: true, + spanArcCenterZ: -radius, + spanArcRadius: radius, + highEdgeHeight, + lowEdgeHeight, + connectionMode: 'manual', + leftEndCondition: 'joined', + rightEndCondition: 'joined', + autoMiterCorners: false, + sideFlashing: false, + leftOverhang: 0, + rightOverhang: 0, + }) + return { + ...parsed, + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } +} + +export function resolveConicalLeanToSurfaceHit( + segment: RoofSegmentNode, + localPosition: readonly [number, number, number], + normal?: readonly [number, number, number], +): LeanToExtensionNode | null { + if (segment.roofType !== 'conical' || !normal) return null + const radius = segment.width / 2 + const radialDistance = Math.hypot(localPosition[0], localPosition[2]) + const hitsCylinderHeight = + localPosition[1] >= -CONICAL_WALL_HIT_TOLERANCE && + localPosition[1] <= segment.wallHeight + CONICAL_WALL_HIT_TOLERANCE + const hitsCylinderRadius = Math.abs(radialDistance - radius) <= CONICAL_WALL_HIT_TOLERANCE + const hasHorizontalNormal = Math.abs(normal[1]) <= 0.35 + return hitsCylinderHeight && hitsCylinderRadius && hasHorizontalNormal + ? resolveConicalLeanToPlacement(segment) + : null +} + +function resolveSegmentPlanPose( + segment: RoofSegmentNode, + nodes: Record, + activeLevelId: AnyNodeId, +): { center: [number, number]; rotationY: number } | null { + if (findLevelAncestorId(segment.id as AnyNodeId, nodes) !== activeLevelId) return null + + const chain: AnyNode[] = [] + let current: AnyNode | undefined = segment + const seen = new Set() + while (current && current.id !== activeLevelId && !seen.has(current.id as AnyNodeId)) { + seen.add(current.id as AnyNodeId) + chain.push(current) + current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined + } + + let x = 0 + let z = 0 + let rotationY = 0 + for (const node of chain.reverse()) { + if (node.type !== 'roof' && node.type !== 'roof-segment') continue + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + x += node.position[0] * cos + node.position[2] * sin + z += -node.position[0] * sin + node.position[2] * cos + rotationY += node.rotation + } + return { center: [x, z], rotationY } +} + +export function findConicalLeanToHostInPlan( + point: readonly [number, number], + nodes: Record, + activeLevelId: AnyNodeId, + options?: { includeOccupied?: boolean }, +): ConicalLeanToPlanHost | null { + let closest: (ConicalLeanToPlanHost & { distance: number }) | null = null + for (const candidate of Object.values(nodes)) { + if (candidate.type !== 'roof-segment' || candidate.roofType !== 'conical') continue + if (!options?.includeOccupied && isConicalLeanToHostOccupied(candidate.id, nodes)) continue + const pose = resolveSegmentPlanPose(candidate, nodes, activeLevelId) + if (!pose) continue + const distance = Math.hypot(point[0] - pose.center[0], point[1] - pose.center[1]) + if (distance > candidate.width / 2 + CONICAL_PLAN_HIT_TOLERANCE) continue + if (closest && distance >= closest.distance) continue + const node = resolveConicalLeanToPlacement(candidate) + if (!node) continue + closest = { segment: candidate, ...pose, node, distance } + } + if (!closest) return null + const { distance: _distance, ...host } = closest + return host +} diff --git a/packages/nodes/src/lean-to-extension/corner-joint.ts b/packages/nodes/src/lean-to-extension/corner-joint.ts index 15a5c12b60..0c1e6017b8 100644 --- a/packages/nodes/src/lean-to-extension/corner-joint.ts +++ b/packages/nodes/src/lean-to-extension/corner-joint.ts @@ -1,10 +1,11 @@ import type { AnyNode, LeanToExtensionNode, WallNode } from '@pascal-app/core' import { bendLocalPoint, isCurvedLeanTo, leanToArcFrameAtLocalX } from './arc' import { leanToWallLocalPose, resolveLeanToLayout } from './layout' +import { applyLeanToWallCornerSpan } from './roof-attachment' export type LeanToCornerSide = 'left' | 'right' export type LeanToPlanPoint = [number, number] -export type LeanToCornerKind = 'convex' | 'concave' +export type LeanToCornerKind = 'convex' | 'concave' | 'linear' export type LeanToCornerJoint = { side: LeanToCornerSide @@ -13,6 +14,7 @@ export type LeanToCornerJoint = { neighborSide: LeanToCornerSide roofExtension: number roofPiece: LeanToPlanPoint[] + roofPieces?: LeanToPlanPoint[][] seam: [LeanToPlanPoint, LeanToPlanPoint] | null beamExtension: number gutterMitre: number @@ -27,11 +29,24 @@ const WALL_CONNECTION_TRIM = 0.002 const PLAN_TOLERANCE = 1e-6 const MIN_CORNER_ANGLE = Math.PI / 6 const MAX_CORNER_ANGLE = (5 * Math.PI) / 6 +const LINEAR_DIRECTION_TOLERANCE = 1e-3 +const LINEAR_JOIN_PLAN_TOLERANCE = 0.03 +const LINEAR_JOIN_HEIGHT_TOLERANCE = 0.02 function planDistance(a: readonly [number, number], b: readonly [number, number]): number { return Math.hypot(a[0] - b[0], a[1] - b[1]) } +function directionsFormSupportedCorner( + away: LeanToPlanPoint | null, + candidateAway: LeanToPlanPoint | null, +): boolean { + if (!(away && candidateAway)) return false + const dot = Math.max(-1, Math.min(1, away[0] * candidateAway[0] + away[1] * candidateAway[1])) + const angle = Math.acos(dot) + return angle >= MIN_CORNER_ANGLE - PLAN_TOLERANCE && angle <= MAX_CORNER_ANGLE + PLAN_TOLERANCE +} + function wallFrame(wall: WallNode) { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] @@ -106,6 +121,11 @@ function cornerKindFromDirections( if (!(outward && candidateOutward && away && candidateAway)) return null const candidateAcrossOwn = outward[0] * candidateAway[0] + outward[1] * candidateAway[1] const ownAcrossCandidate = candidateOutward[0] * away[0] + candidateOutward[1] * away[1] + const outwardDot = outward[0] * candidateOutward[0] + outward[1] * candidateOutward[1] + const awayDot = away[0] * candidateAway[0] + away[1] * candidateAway[1] + if (outwardDot >= 1 - LINEAR_DIRECTION_TOLERANCE && awayDot <= -1 + LINEAR_DIRECTION_TOLERANCE) { + return 'linear' + } if (candidateAcrossOwn < -PLAN_TOLERANCE && ownAcrossCandidate < -PLAN_TOLERANCE) { return 'convex' } @@ -115,6 +135,102 @@ function cornerKindFromDirections( return null } +function roofEndWorldPoint( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, +): LeanToPlanPoint | null { + const layout = resolveLeanToLayout(leanTo) + const sign = side === 'left' ? -1 : 1 + return leanToPointToWorld(wall, leanTo, layout.roofCenterX + sign * (layout.roofWidth / 2), 0) +} + +function candidateRoofSideAtPoint( + wall: WallNode, + leanTo: LeanToExtensionNode, + point: readonly [number, number], +): LeanToCornerSide | null { + const left = roofEndWorldPoint(wall, leanTo, 'left') + const right = roofEndWorldPoint(wall, leanTo, 'right') + if (left && planDistance(left, point) <= LINEAR_JOIN_PLAN_TOLERANCE) return 'left' + if (right && planDistance(right, point) <= LINEAR_JOIN_PLAN_TOLERANCE) return 'right' + return null +} + +function resolveLinearJoint( + wall: WallNode, + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + candidateWall: WallNode, + candidate: LeanToExtensionNode, + candidateSide: LeanToCornerSide, +): Pick | null { + const layout = resolveLeanToLayout(leanTo) + const candidateLayout = resolveLeanToLayout(candidate) + const sign = side === 'left' ? -1 : 1 + const candidateSign = candidateSide === 'left' ? -1 : 1 + const sideX = layout.roofCenterX + sign * (layout.roofWidth / 2) + const candidateSideX = + candidateLayout.roofCenterX + candidateSign * (candidateLayout.roofWidth / 2) + const edges = roofPlanEdges(leanTo) + const candidateEdges = roofPlanEdges(candidate) + const ownBack = leanToPointToWorld(wall, leanTo, sideX, edges.back) + const ownFront = leanToPointToWorld(wall, leanTo, sideX, edges.front) + const candidateBack = leanToPointToWorld( + candidateWall, + candidate, + candidateSideX, + candidateEdges.back, + ) + const candidateFront = leanToPointToWorld( + candidateWall, + candidate, + candidateSideX, + candidateEdges.front, + ) + if (!(ownBack && ownFront && candidateBack && candidateFront)) return null + if ( + planDistance(ownBack, candidateBack) > LINEAR_JOIN_PLAN_TOLERANCE || + planDistance(ownFront, candidateFront) > LINEAR_JOIN_PLAN_TOLERANCE + ) { + return null + } + + for (const point of [ownBack, ownFront] as const) { + const ownHeight = leanToTopHeightAtWorld(wall, leanTo, point) + const candidateHeight = leanToTopHeightAtWorld(candidateWall, candidate, point) + if ( + ownHeight === null || + candidateHeight === null || + Math.abs(ownHeight - candidateHeight) > LINEAR_JOIN_HEIGHT_TOLERANCE + ) { + return null + } + } + + const ownBeam = leanToPointToWorld(wall, leanTo, sideX, layout.beamZ) + const candidateBeam = leanToPointToWorld( + candidateWall, + candidate, + candidateSideX, + candidateLayout.beamZ, + ) + if (!(ownBeam && candidateBeam)) return null + if (planDistance(ownBeam, candidateBeam) > LINEAR_JOIN_PLAN_TOLERANCE) return null + + const structuralSideX = sign * (layout.span / 2) + const beamExtension = Math.max(0, sign * (sideX - structuralSideX)) + return { + roofPiece: [], + seam: [ + [sideX, edges.back], + [sideX, edges.front], + ], + beamExtension, + sharedPostPosition: [sideX, 0, layout.beamZ], + } +} + function cornerInteriorAngle( wall: WallNode, leanTo: LeanToExtensionNode, @@ -138,12 +254,18 @@ function isSupportedHostCorner( candidate: LeanToExtensionNode, candidateSide: LeanToCornerSide, ): boolean { - const away = awayFromEndChordDirection(wall, leanTo, side) - const candidateAway = awayFromEndChordDirection(candidateWall, candidate, candidateSide) - if (!(away && candidateAway)) return false - const dot = Math.max(-1, Math.min(1, away[0] * candidateAway[0] + away[1] * candidateAway[1])) - const angle = Math.acos(dot) - return angle >= MIN_CORNER_ANGLE - PLAN_TOLERANCE && angle <= MAX_CORNER_ANGLE + PLAN_TOLERANCE + if ( + directionsFormSupportedCorner( + awayFromEndDirection(wall, leanTo, side), + awayFromEndDirection(candidateWall, candidate, candidateSide), + ) + ) { + return true + } + return directionsFormSupportedCorner( + awayFromEndChordDirection(wall, leanTo, side), + awayFromEndChordDirection(candidateWall, candidate, candidateSide), + ) } function leanToPointToWorld( @@ -418,6 +540,60 @@ function polygonSignedArea(polygon: readonly LeanToPlanPoint[]): number { return area / 2 } +function pointInPlanPolygon( + point: readonly [number, number], + polygon: readonly LeanToPlanPoint[], +): boolean { + let inside = false + for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { + const current = polygon[index]! + const prior = polygon[previous]! + const edgeX = current[0] - prior[0] + const edgeZ = current[1] - prior[1] + const cross = (point[0] - prior[0]) * edgeZ - (point[1] - prior[1]) * edgeX + const dot = + (point[0] - prior[0]) * (point[0] - current[0]) + + (point[1] - prior[1]) * (point[1] - current[1]) + if (Math.abs(cross) <= PLAN_TOLERANCE && dot <= PLAN_TOLERANCE) return true + if ( + current[1] > point[1] !== prior[1] > point[1] && + point[0] < + ((prior[0] - current[0]) * (point[1] - current[1])) / (prior[1] - current[1]) + current[0] + ) { + inside = !inside + } + } + return inside +} + +function connectedPlanPolygonComponent( + polygons: LeanToPlanPoint[][], + probe: readonly [number, number], +): LeanToPlanPoint[][] { + const connected = new Set() + const queue = polygons.flatMap((polygon, index) => + pointInPlanPolygon(probe, polygon) ? [index] : [], + ) + for (const index of queue) connected.add(index) + + while (queue.length > 0) { + const currentIndex = queue.shift()! + const current = polygons[currentIndex]! + for (let candidateIndex = 0; candidateIndex < polygons.length; candidateIndex++) { + if (connected.has(candidateIndex)) continue + const candidate = polygons[candidateIndex]! + const touches = + current.some((point) => pointInPlanPolygon(point, candidate)) || + candidate.some((point) => pointInPlanPolygon(point, current)) + if (!touches) continue + connected.add(candidateIndex) + queue.push(candidateIndex) + } + } + + return polygons.filter((_, index) => connected.has(index)) +} + function intersectConvexPolygons( subject: readonly LeanToPlanPoint[], clip: readonly LeanToPlanPoint[], @@ -452,6 +628,81 @@ function intersectConvexPolygons( return result } +function clipPolygonToHalfPlane( + polygon: readonly LeanToPlanPoint[], + edgeStart: LeanToPlanPoint, + edgeEnd: LeanToPlanPoint, + orientation: number, + keepInside: boolean, +): LeanToPlanPoint[] { + const clipped: LeanToPlanPoint[] = [] + const edgeSide = (point: readonly [number, number]) => + orientation * + ((edgeEnd[0] - edgeStart[0]) * (point[1] - edgeStart[1]) - + (edgeEnd[1] - edgeStart[1]) * (point[0] - edgeStart[0])) + for (let index = 0; index < polygon.length; index++) { + const current = polygon[index]! + const next = polygon[(index + 1) % polygon.length]! + const currentSide = edgeSide(current) + const nextSide = edgeSide(next) + const currentInside = keepInside + ? currentSide >= -PLAN_TOLERANCE + : currentSide <= PLAN_TOLERANCE + const nextInside = keepInside ? nextSide >= -PLAN_TOLERANCE : nextSide <= PLAN_TOLERANCE + if (currentInside) clipped.push([current[0], current[1]]) + if (currentInside === nextInside) continue + const ratio = currentSide / (currentSide - nextSide) + clipped.push([ + current[0] + (next[0] - current[0]) * ratio, + current[1] + (next[1] - current[1]) * ratio, + ]) + } + return clipped.filter( + (point, index) => index === 0 || planDistance(point, clipped[index - 1]!) > PLAN_TOLERANCE, + ) +} + +function subtractConvexPolygon( + subject: readonly LeanToPlanPoint[], + clip: readonly LeanToPlanPoint[], +): LeanToPlanPoint[][] { + if (subject.length < 3) return [] + if (clip.length < 3) return [subject.map((point) => [point[0], point[1]])] + const orientation = Math.sign(polygonSignedArea(clip)) || 1 + let remaining = subject.map((point) => [point[0], point[1]] as LeanToPlanPoint) + const outside: LeanToPlanPoint[][] = [] + for (let index = 0; index < clip.length && remaining.length >= 3; index++) { + const edgeStart = clip[index]! + const edgeEnd = clip[(index + 1) % clip.length]! + const fragment = clipPolygonToHalfPlane(remaining, edgeStart, edgeEnd, orientation, false) + if (fragment.length >= 3 && Math.abs(polygonSignedArea(fragment)) > PLAN_TOLERANCE) { + outside.push(fragment) + } + remaining = clipPolygonToHalfPlane(remaining, edgeStart, edgeEnd, orientation, true) + } + return outside +} + +function roofWorldFacets(wall: WallNode, leanTo: LeanToExtensionNode): LeanToPlanPoint[][] { + const layout = resolveLeanToLayout(leanTo) + const edges = roofPlanEdges(leanTo) + const facetCount = isCurvedLeanTo(leanTo) + ? Math.max(4, Math.min(32, Math.ceil(layout.roofWidth / 0.4))) + : 1 + const leftX = layout.roofCenterX - layout.roofWidth / 2 + const facetWidth = layout.roofWidth / facetCount + return Array.from({ length: facetCount }, (_, index) => { + const minX = leftX + index * facetWidth + const maxX = index === facetCount - 1 ? leftX + layout.roofWidth : minX + facetWidth + return [ + leanToPointToWorld(wall, leanTo, minX, edges.back), + leanToPointToWorld(wall, leanTo, maxX, edges.back), + leanToPointToWorld(wall, leanTo, maxX, edges.front), + leanToPointToWorld(wall, leanTo, minX, edges.front), + ].flatMap((point) => (point ? [point] : [])) + }).filter((polygon) => polygon.length >= 3) +} + function sharedRoofSeam( wall: WallNode, leanTo: LeanToExtensionNode, @@ -570,22 +821,164 @@ function resolveConcaveRoofPiece( } } +function resolveCurvedStraightConcaveRoofPiece( + leanTo: LeanToExtensionNode, + wall: WallNode, + side: LeanToCornerSide, + candidate: LeanToExtensionNode, + candidateWall: WallNode, + candidateSide: LeanToCornerSide, +): { + piece: LeanToPlanPoint[] + pieces: LeanToPlanPoint[][] + seam: [LeanToPlanPoint, LeanToPlanPoint] | null +} | null { + const ownCurved = isCurvedLeanTo(leanTo) + const candidateCurved = isCurvedLeanTo(candidate) + if (ownCurved === candidateCurved) return null + const curved = ownCurved ? leanTo : candidate + const curvedWall = ownCurved ? wall : candidateWall + const curvedSide = ownCurved ? side : candidateSide + const straight = ownCurved ? candidate : leanTo + const straightWall = ownCurved ? candidateWall : wall + const curvedLayout = resolveLeanToLayout(curved) + + const curvedEdges = roofPlanEdges(curved) + const curvedSideSign = curvedSide === 'left' ? -1 : 1 + const curvedSideX = curvedLayout.roofCenterX + curvedSideSign * (curvedLayout.roofWidth / 2) + const probeWorld = leanToPointToWorld( + curvedWall, + curved, + curvedSideX - curvedSideSign * Math.min(0.1, curvedLayout.roofWidth / 4), + curvedEdges.back, + ) + if (!probeWorld) return null + const worldHeightDelta = (point: readonly [number, number]) => { + const curvedHeight = leanToTopHeightAtWorld(curvedWall, curved, point) + const straightHeight = leanToTopHeightAtWorld(straightWall, straight, point) + return curvedHeight === null || straightHeight === null ? null : curvedHeight - straightHeight + } + const probeDelta = worldHeightDelta(probeWorld) + if (probeDelta === null || Math.abs(probeDelta) <= PLAN_TOLERANCE) return null + const curvedRetainedSign = Math.sign(probeDelta) + const straightSide = ownCurved ? candidateSide : side + const straightLayout = resolveLeanToLayout(straight) + const straightEdges = roofPlanEdges(straight) + const straightSideSign = straightSide === 'left' ? -1 : 1 + const straightProbe = leanToPointToWorld( + straightWall, + straight, + straightLayout.roofCenterX - + straightSideSign * + (straightLayout.roofWidth / 2 - Math.min(0.1, straightLayout.roofWidth / 4)), + (straightEdges.back + straightEdges.front) / 2, + ) + if (!straightProbe) return null + + // The equal-height cut only divides the shared footprint. Applying it to the + // whole curved band removes roof area that the straight neighbor never covers. + const curvedFacets = roofWorldFacets(curvedWall, curved) + const straightBase = roofWorldFacets(straightWall, straight)[0] + if (!straightBase) return null + const overlaps = curvedFacets + .map((facet) => intersectConvexPolygons(facet, straightBase)) + .filter((polygon) => polygon.length >= 3) + if (overlaps.length === 0) return null + + let retainedWorld: LeanToPlanPoint[][] + if (ownCurved) { + retainedWorld = curvedFacets.flatMap((facet) => { + const overlap = intersectConvexPolygons(facet, straightBase) + const exclusive = subtractConvexPolygon(facet, straightBase) + const retainedOverlap = clipToRetainedRoofSide(overlap, worldHeightDelta, curvedRetainedSign) + return [...exclusive, ...(retainedOverlap.length >= 3 ? [retainedOverlap] : [])] + }) + } else { + let exclusive = [straightBase] + for (const facet of curvedFacets) { + exclusive = exclusive.flatMap((polygon) => subtractConvexPolygon(polygon, facet)) + } + const connectedExclusive = connectedPlanPolygonComponent(exclusive, straightProbe) + if (connectedExclusive.length > 0) exclusive = connectedExclusive + const retainedOverlap = overlaps.flatMap((overlap) => { + const piece = clipToRetainedRoofSide(overlap, worldHeightDelta, -curvedRetainedSign) + return piece.length >= 3 ? [piece] : [] + }) + retainedWorld = [...exclusive, ...retainedOverlap] + } + + const seamWorld: LeanToPlanPoint[] = [] + for (const overlap of overlaps) { + for (let index = 0; index < overlap.length; index++) { + const current = overlap[index]! + const next = overlap[(index + 1) % overlap.length]! + const currentDelta = worldHeightDelta(current) + const nextDelta = worldHeightDelta(next) + if (currentDelta === null || nextDelta === null) continue + if (Math.abs(currentDelta) <= PLAN_TOLERANCE) seamWorld.push(current) + if (currentDelta * nextDelta >= 0) continue + const ratio = currentDelta / (currentDelta - nextDelta) + seamWorld.push([ + current[0] + (next[0] - current[0]) * ratio, + current[1] + (next[1] - current[1]) * ratio, + ]) + } + } + const uniqueSeam = seamWorld.filter( + (point, index) => + seamWorld.findIndex( + (candidatePoint) => planDistance(point, candidatePoint) <= PLAN_TOLERANCE, + ) === index, + ) + let seamEndpoints: [LeanToPlanPoint, LeanToPlanPoint] | null = null + for (const first of uniqueSeam) { + for (const second of uniqueSeam) { + if (!seamEndpoints || planDistance(first, second) > planDistance(...seamEndpoints)) { + seamEndpoints = [first, second] + } + } + } + const pieces = retainedWorld.flatMap((polygon) => { + const localized = polygon.map((point) => worldPointToLeanTo(wall, leanTo, point)) + if (localized.some((point) => !point)) return [] + const piece = localized as LeanToPlanPoint[] + return piece.length >= 3 && Math.abs(polygonSignedArea(piece)) > PLAN_TOLERANCE ? [piece] : [] + }) + const localizedSeam = seamEndpoints?.map((point) => worldPointToLeanTo(wall, leanTo, point)) + const seam = + localizedSeam?.[0] && localizedSeam[1] + ? ([localizedSeam[0], localizedSeam[1]] as [LeanToPlanPoint, LeanToPlanPoint]) + : null + if (pieces.length === 0 || !seam) return null + + return { piece: pieces[0]!, pieces, seam } +} + export function applyLeanToCornerRoofPieces( base: LeanToPlanPoint[], joints: Partial>, ): LeanToPlanPoint[][] { - let retained = base + let retained = [base] const additions: LeanToPlanPoint[][] = [] for (const side of ['left', 'right'] as const) { const joint = joints[side] if (!joint || joint.roofPiece.length < 3) continue if (joint.kind === 'concave') { - retained = intersectConvexPolygons(retained, joint.roofPiece) + const clips = joint.roofPieces ?? [joint.roofPiece] + retained = retained.flatMap((subject) => + clips.flatMap((clip) => { + const intersection = intersectConvexPolygons(subject, clip) + return intersection.length >= 3 && + Math.abs(polygonSignedArea(intersection)) > PLAN_TOLERANCE + ? [intersection] + : [] + }), + ) } else { additions.push(joint.roofPiece) } } - return [...(retained.length >= 3 ? [retained] : []), ...additions] + return [...retained, ...additions] } function resolveRoofPiece( @@ -688,8 +1081,9 @@ export function resolveLeanToCornerJoints( wall: WallNode | undefined, nodes: Record | undefined, ): Partial> { - if (!leanTo.autoMiterCorners || !wall || !nodes) return {} + if (!wall || !nodes) return {} if (!wallFrame(wall)) return {} + const cornerLeanTo = applyLeanToWallCornerSpan(leanTo, wall) const tolerance = Math.max( 0.35, (wall.thickness ?? 0.1) + Math.max(leanTo.leftOverhang, leanTo.rightOverhang), @@ -697,116 +1091,197 @@ export function resolveLeanToCornerJoints( const joints: Partial> = {} for (const side of ['left', 'right'] as const) { - const endpoint = endWorldPoint(wall, leanTo, side) - if (!endpoint) continue + const endpoint = endWorldPoint(wall, cornerLeanTo, side) + const roofEndpoint = roofEndWorldPoint(wall, cornerLeanTo, side) + if (!(endpoint && roofEndpoint)) continue for (const candidate of Object.values(nodes)) { if (candidate.type !== 'lean-to-extension' || candidate.id === leanTo.id) continue - if (!candidate.autoMiterCorners) continue const candidateWall = candidate.parentId ? nodes[candidate.parentId] : undefined if (candidateWall?.type !== 'wall' || candidateWall.parentId !== wall.parentId) continue if (!wallFrame(candidateWall)) continue - const neighborSide = candidateSideAtPoint(candidateWall, candidate, endpoint, tolerance) + const cornerCandidate = applyLeanToWallCornerSpan(candidate, candidateWall) + const linearNeighborSide = candidateRoofSideAtPoint( + candidateWall, + cornerCandidate, + roofEndpoint, + ) + if (linearNeighborSide) { + const linearKind = cornerKindFromDirections( + wall, + cornerLeanTo, + side, + candidateWall, + cornerCandidate, + linearNeighborSide, + ) + const linearJoint = + linearKind === 'linear' + ? resolveLinearJoint( + wall, + cornerLeanTo, + side, + candidateWall, + cornerCandidate, + linearNeighborSide, + ) + : null + if (linearJoint) { + joints[side] = { + side, + kind: 'linear', + neighborId: candidate.id, + neighborSide: linearNeighborSide, + roofExtension: 0, + roofPiece: linearJoint.roofPiece, + seam: linearJoint.seam, + beamExtension: linearJoint.beamExtension, + gutterMitre: 0, + sharedPostOwner: String(cornerLeanTo.id) < String(candidate.id), + sharedPostPosition: linearJoint.sharedPostPosition, + } + break + } + } + if (!leanTo.autoMiterCorners || !candidate.autoMiterCorners) continue + const neighborSide = candidateSideAtPoint(candidateWall, cornerCandidate, endpoint, tolerance) if (!neighborSide) continue const kind = cornerKindFromDirections( wall, - leanTo, + cornerLeanTo, side, candidateWall, - candidate, + cornerCandidate, neighborSide, ) - if (!kind) continue - if (kind === 'concave' && (isCurvedLeanTo(leanTo) || isCurvedLeanTo(candidate))) continue - if (!isSupportedHostCorner(wall, leanTo, side, candidateWall, candidate, neighborSide)) { + if (!kind || kind === 'linear') continue + if ( + !isSupportedHostCorner( + wall, + cornerLeanTo, + side, + candidateWall, + cornerCandidate, + neighborSide, + ) + ) { continue } const interiorAngle = cornerInteriorAngle( wall, - leanTo, + cornerLeanTo, side, candidateWall, - candidate, + cornerCandidate, neighborSide, ) if (interiorAngle === null) continue - const candidateLayout = resolveLeanToLayout(candidate) - const layout = resolveLeanToLayout(leanTo) + const candidateLayout = resolveLeanToLayout(cornerCandidate) + const layout = resolveLeanToLayout(cornerLeanTo) + // A curved concave join is trimmed at the shared roof seam. Extending + // the run from a straight chord into the curved band is not a valid + // construction: the line/circle intersection can select the distant + // branch and create runaway beam and gutter lengths. + const curvedConcaveJoint = + kind === 'concave' && (isCurvedLeanTo(cornerLeanTo) || isCurvedLeanTo(cornerCandidate)) const sideSign = side === 'left' ? -1 : 1 - const ownEdges = roofPlanEdges(leanTo) - const candidateEdges = roofPlanEdges(candidate) + const ownEdges = roofPlanEdges(cornerLeanTo) + const candidateEdges = roofPlanEdges(cornerCandidate) const roofSideX = layout.roofCenterX + sideSign * (layout.roofWidth / 2) - const roofExtension = - extensionToRunIntersection( - wall, - leanTo, - side, - roofSideX, - ownEdges.front, - candidateWall, - candidate, - candidateEdges.front, - ) ?? 0 + const roofExtension = curvedConcaveJoint + ? 0 + : (extensionToRunIntersection( + wall, + cornerLeanTo, + side, + roofSideX, + ownEdges.front, + candidateWall, + cornerCandidate, + candidateEdges.front, + ) ?? 0) const candidateSideSign = neighborSide === 'left' ? -1 : 1 const candidateRoofSideX = candidateLayout.roofCenterX + candidateSideSign * (candidateLayout.roofWidth / 2) - const candidateRoofExtension = - extensionToRunIntersection( - candidateWall, - candidate, - neighborSide, - candidateRoofSideX, - candidateEdges.front, - wall, - leanTo, - ownEdges.front, - ) ?? 0 + const candidateRoofExtension = curvedConcaveJoint + ? 0 + : (extensionToRunIntersection( + candidateWall, + cornerCandidate, + neighborSide, + candidateRoofSideX, + candidateEdges.front, + wall, + cornerLeanTo, + ownEdges.front, + ) ?? 0) const curvedStraightRoof = kind === 'convex' ? resolveCurvedStraightRoofPiece( - leanTo, + cornerLeanTo, wall, side, roofExtension, - candidate, + cornerCandidate, candidateWall, neighborSide, candidateRoofExtension, ) : null + const curvedStraightConcaveRoof = + kind === 'concave' + ? resolveCurvedStraightConcaveRoofPiece( + cornerLeanTo, + wall, + side, + cornerCandidate, + candidateWall, + neighborSide, + ) + : null const roof = curvedStraightRoof ?? + curvedStraightConcaveRoof ?? (kind === 'convex' - ? resolveRoofPiece(leanTo, wall, side, roofExtension, candidate, candidateWall) - : resolveConcaveRoofPiece(leanTo, wall, side, candidate, candidateWall)) - const seam = curvedStraightRoof - ? roof.seam - : sharedRoofSeam( + ? resolveRoofPiece( + cornerLeanTo, + wall, + side, + roofExtension, + cornerCandidate, + candidateWall, + ) + : resolveConcaveRoofPiece(cornerLeanTo, wall, side, cornerCandidate, candidateWall)) + const seam = + curvedStraightRoof || curvedStraightConcaveRoof + ? roof.seam + : sharedRoofSeam( + wall, + cornerLeanTo, + side, + roofExtension, + candidateWall, + cornerCandidate, + neighborSide, + candidateRoofExtension, + kind, + ) + const beamExtension = curvedConcaveJoint + ? 0 + : (extensionToRunIntersection( wall, - leanTo, + cornerLeanTo, side, - roofExtension, + sideSign * (layout.span / 2), + layout.beamZ, candidateWall, - candidate, - neighborSide, - candidateRoofExtension, - kind, - ) - const beamExtension = - extensionToRunIntersection( - wall, - leanTo, - side, - sideSign * (layout.span / 2), - layout.beamZ, - candidateWall, - candidate, - candidateLayout.beamZ, - ) ?? 0 - const gutterAway = gutterAwayFromJointDirection(wall, leanTo, side, roofExtension) + cornerCandidate, + candidateLayout.beamZ, + ) ?? 0) + const gutterAway = gutterAwayFromJointDirection(wall, cornerLeanTo, side, roofExtension) const candidateGutterAway = gutterAwayFromJointDirection( candidateWall, - candidate, + cornerCandidate, neighborSide, candidateRoofExtension, ) @@ -829,10 +1304,11 @@ export function resolveLeanToCornerJoints( neighborSide, roofExtension, roofPiece: roof.piece, + roofPieces: curvedStraightConcaveRoof?.pieces, seam: seam ?? roof.seam, beamExtension, gutterMitre: (kind === 'concave' ? -1 : 1) * ((Math.PI - gutterInteriorAngle) / 2), - sharedPostOwner: String(leanTo.id) < String(candidate.id), + sharedPostOwner: String(cornerLeanTo.id) < String(candidate.id), sharedPostPosition: [ (side === 'left' ? -layout.span / 2 : layout.span / 2) + (side === 'left' ? -beamExtension : beamExtension), diff --git a/packages/nodes/src/lean-to-extension/definition.test.ts b/packages/nodes/src/lean-to-extension/definition.test.ts index 5eefb9be91..05f00a7253 100644 --- a/packages/nodes/src/lean-to-extension/definition.test.ts +++ b/packages/nodes/src/lean-to-extension/definition.test.ts @@ -5,7 +5,10 @@ import { type LeanToExtensionNode, LeanToExtensionNode as LeanToExtensionNodeSchema, type LinearResizeHandle, + RoofSegmentNode, + WallNode, } from '@pascal-app/core' +import { resolveConicalLeanToPlacement } from './conical-host' import { leanToExtensionDefinition } from './definition' import { resolveLeanToLayout } from './layout' @@ -28,7 +31,7 @@ function handles(): HandleDescriptor[] { } function linearHandle( - axis: 'x' | 'z', + axis: 'x' | 'y' | 'z', anchor: 'min' | 'max', ): LinearResizeHandle { const handle = handles().find( @@ -43,7 +46,67 @@ function spanHandle(anchor: 'min' | 'max'): LinearResizeHandle { + return linearHandle('y', 'min') +} + +function circularRadiusHandles(): LinearResizeHandle[] { + return handles().filter( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.measureLabel === 'Host radius', + ) +} + +function pitchHandle(): LinearResizeHandle { + const handle = handles().find( + (candidate): candidate is LinearResizeHandle => + candidate.kind === 'linear-resize' && + candidate.axis === 'y' && + typeof candidate.min === 'function', + ) + if (!handle) throw new Error('Missing pitch handle') + return handle +} + +function rotationHandle() { + const handle = handles().find((candidate) => candidate.kind === 'arc-resize') + if (handle?.kind !== 'arc-resize') throw new Error('Missing rotation handle') + return handle +} + describe('lean-to extension span handles', () => { + test('rotates only a freestanding canopy', () => { + const freestanding = node({ + parentId: 'level_free_rotate', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + rotation: [0, 0, 0], + }) + const handle = rotationHandle() + + expect(handle.visible?.(freestanding, undefined as never)).toBe(true) + expect(handle.visible?.(node(), undefined as never)).toBe(false) + expect(handle.apply(freestanding, Math.PI / 4, undefined as never)).toEqual({ + rotation: [0, -Math.PI / 4, 0], + }) + }) + + test('resizes a rotated freestanding canopy along its local span axis', () => { + const freestanding = node({ + parentId: 'level_free_resize', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + position: [10, 0, 20], + rotation: [0, Math.PI / 2, 0], + span: 4, + }) + + expect(spanHandle('min').apply(freestanding, 6, undefined as never)).toMatchObject({ + span: 6, + position: [10, 0, 19], + }) + }) + test('exposes right and left span arrows on the whole extension', () => { expect(spanHandle('min').placement.rotationY?.(node(), undefined as never)).toBe(0) expect(spanHandle('max').placement.rotationY?.(node(), undefined as never)).toBe(Math.PI) @@ -65,6 +128,67 @@ describe('lean-to extension span handles', () => { ]) }) + test('shows height and horizontal radius arrows on a closed conical loop', () => { + const host = RoofSegmentNode.parse({ + id: 'rseg_circular_visibility', + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 3, + }) + const circular = resolveConicalLeanToPlacement(host, { + id: 'leanto_circular_visibility', + })! + const nodes = { [host.id]: host, [circular.id]: circular } as Record + const sceneApi = { get: (id: string) => nodes[id], nodes: () => nodes } as never + + expect(spanHandle('min').visible?.(circular, sceneApi)).toBe(false) + expect(spanHandle('max').visible?.(circular, sceneApi)).toBe(false) + expect(heightHandle().visible?.(circular, sceneApi) ?? true).toBe(true) + expect(circularRadiusHandles()).toHaveLength(2) + expect( + circularRadiusHandles().every((handle) => handle.visible?.(circular, sceneApi) ?? true), + ).toBe(true) + expect(heightHandle().apply(circular, 3.75, sceneApi)).toMatchObject({ + highEdgeHeight: 3.75, + hostHeightOffset: 0.75, + }) + }) + + test('resizes the circular host and keeps the closed loop attached', () => { + const host = RoofSegmentNode.parse({ + id: 'rseg_circular_handle', + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 3, + }) + const circular = resolveConicalLeanToPlacement(host, { id: 'leanto_circular_handle' })! + const nodes = { [host.id]: host, [circular.id]: circular } as Record + const updates: Array<{ id: string; patch: Partial }> = [] + const sceneApi = { + get: (id: string) => nodes[id], + nodes: () => nodes, + update: (id: string, patch: Partial) => updates.push({ id, patch }), + } as never + const handle = circularRadiusHandles()[0]! + + expect(handle.currentValue(circular)).toBe(4) + const patch = handle.apply(circular, 5, sceneApi) + expect(patch).toMatchObject({ + span: 10 * Math.PI, + spanArcCenterZ: -5, + spanArcRadius: 5, + position: [0, 0, 5], + }) + expect(new Map(handle.previewOverrides?.(circular, 5, sceneApi) ?? []).get(host.id)).toEqual({ + width: 10, + depth: 10, + }) + handle.commit?.(circular, patch, sceneApi) + expect(updates).toContainEqual({ id: host.id, patch: { width: 10, depth: 10 } }) + }) + test('places projection arrow at the same low roof edge height', () => { const leanTo = node() const layout = resolveLeanToLayout(leanTo) @@ -76,6 +200,34 @@ describe('lean-to extension span handles', () => { ]) }) + test('places an upward pitch arrow beyond the front eave', () => { + const leanTo = node({ lowOverhang: 0.25 }) + const layout = resolveLeanToLayout(leanTo) + const handle = pitchHandle() + + expect(handle.axis).toBe('y') + expect(handle.placement.position(leanTo, undefined as never)).toEqual([ + 0, + layout.lowEdgeHeight + 0.25, + leanTo.projection + leanTo.lowOverhang + 0.3, + ]) + }) + + test('changes pitch from the front edge while keeping the wall edge fixed', () => { + const leanTo = node({ highEdgeHeight: 3.2, pitch: 12 }) + const handle = pitchHandle() + const currentLowEdge = handle.currentValue(leanTo) + const flatter = handle.apply(leanTo, currentLowEdge + 0.25, undefined as never) + const steeper = handle.apply(leanTo, currentLowEdge - 0.25, undefined as never) + + expect(flatter.highEdgeHeight).toBeUndefined() + expect(steeper.highEdgeHeight).toBeUndefined() + expect(flatter.pitch).toBeLessThan(leanTo.pitch) + expect(steeper.pitch).toBeGreaterThan(leanTo.pitch) + expect(flatter.lowEdgeHeight).toBeCloseTo(currentLowEdge + 0.25) + expect(steeper.lowEdgeHeight).toBeCloseTo(currentLowEdge - 0.25) + }) + test('resizes span only from the dragged side', () => { const leanTo = node() @@ -106,6 +258,62 @@ describe('lean-to extension span handles', () => { }) }) + test('snaps a resized side to the wall end and aligns with the neighboring roof plane', () => { + const wall = WallNode.parse({ + id: 'wall_resize_left', + parentId: 'level_test', + start: [0, 0], + end: [5, 0], + }) + const adjacentWall = WallNode.parse({ + id: 'wall_resize_right', + parentId: 'level_test', + start: [5, 0], + end: [10, 0], + }) + const moving = node({ + parentId: wall.id, + position: [2, 0, 0.05], + span: 2, + highEdgeHeight: 2.8, + pitch: 8, + leftOverhang: 0, + rightOverhang: 0, + }) + const adjacent = LeanToExtensionNodeSchema.parse({ + id: 'leanto_resize_neighbor', + parentId: adjacentWall.id, + position: [1, 0, 0.05], + span: 2, + highEdgeHeight: 3.4, + pitch: 12, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wall.id]: wall, + [adjacentWall.id]: adjacentWall, + [moving.id]: moving, + [adjacent.id]: adjacent, + } as Record + const sceneApi = { + get: (id: string) => nodes[id], + nodes: () => nodes, + } as never + const handle = spanHandle('min') + + const snappedSpan = handle.connectionSnap?.(moving, 3.85, sceneApi) + expect(snappedSpan).toBe(4) + expect(handle.apply(moving, snappedSpan ?? 3.85, sceneApi)).toMatchObject({ + span: 4, + position: [3, 0, 0.05], + highEdgeHeight: 3.4, + pitch: 12, + autoSpan: false, + }) + expect(typeof handle.max === 'function' ? handle.max(moving, sceneApi) : handle.max).toBe(4) + }) + test('previews managed roof-segment span while dragging', () => { const leanTo = node({ children: ['roof_test' as never] }) const nodes = { @@ -125,14 +333,90 @@ describe('lean-to extension span handles', () => { children: [], }, } as unknown as Record + const sceneApi = { get: (id: string) => nodes[id], nodes: () => nodes } as never - const preview = new Map( - spanHandle('min').previewOverrides?.(leanTo, 6, { nodes: () => nodes } as never) ?? [], - ) + const preview = new Map(spanHandle('min').previewOverrides?.(leanTo, 6, sceneApi) ?? []) expect(preview.get('rseg_test' as never)).toMatchObject({ roofType: 'shed', width: 6 + leanTo.leftOverhang + leanTo.rightOverhang, }) }) + + test('previews the managed roof at the in-flight wall-side height', () => { + const leanTo = node({ children: ['roof_test' as never], highEdgeHeight: 2.8 }) + const nodes = { + [leanTo.id]: leanTo, + roof_test: { + id: 'roof_test', + type: 'roof', + parentId: leanTo.id, + metadata: { managedByLeanTo: leanTo.id, leanToRole: 'roof' }, + children: ['rseg_test'], + }, + rseg_test: { + id: 'rseg_test', + type: 'roof-segment', + parentId: 'roof_test', + metadata: { managedByLeanTo: leanTo.id, leanToRole: 'roof-segment' }, + children: [], + }, + } as unknown as Record + const sceneApi = { get: (id: string) => nodes[id], nodes: () => nodes } as never + + const initialPreview = new Map(heightHandle().previewOverrides?.(leanTo, 2.8, sceneApi) ?? []) + const raisedPreview = new Map(heightHandle().previewOverrides?.(leanTo, 3.4, sceneApi) ?? []) + const initialPosition = initialPreview.get('rseg_test' as never)?.position + const raisedPosition = raisedPreview.get('rseg_test' as never)?.position + + expect(initialPosition).toBeDefined() + expect(raisedPosition?.[1] - initialPosition?.[1]).toBeCloseTo(0.6) + }) + + test('connects the high edge with an adjacent lean-to', () => { + const wall = WallNode.parse({ + id: 'wall_left', + parentId: 'level_test', + start: [0, 0], + end: [5, 0], + }) + const adjacentWall = WallNode.parse({ + id: 'wall_right', + parentId: 'level_test', + start: [5, 0], + end: [10, 0], + }) + const moving = node({ + parentId: wall.id, + position: [4, 0, 0.05], + span: 2, + highEdgeHeight: 2.8, + leftOverhang: 0, + rightOverhang: 0, + }) + const adjacent = LeanToExtensionNodeSchema.parse({ + id: 'leanto_adjacent', + parentId: adjacentWall.id, + position: [1, 0, 0.05], + span: 2, + highEdgeHeight: 3.4, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wall.id]: wall, + [adjacentWall.id]: adjacentWall, + [moving.id]: moving, + [adjacent.id]: adjacent, + } as Record + const sceneApi = { + get: (id: string) => nodes[id], + nodes: () => nodes, + } as never + + const snap = heightHandle().connectionSnap + expect(snap?.(moving, 3.34, sceneApi)).toBe(3.4) + expect(snap?.(moving, 3.6, sceneApi)).toBe(3.6) + expect(snap?.({ ...moving, position: [2, 0, 0.05] }, 3.34, sceneApi)).toBe(3.34) + }) }) diff --git a/packages/nodes/src/lean-to-extension/definition.ts b/packages/nodes/src/lean-to-extension/definition.ts index af914d0f72..d19a52ffee 100644 --- a/packages/nodes/src/lean-to-extension/definition.ts +++ b/packages/nodes/src/lean-to-extension/definition.ts @@ -1,29 +1,30 @@ -import type { - AnyNode, - AnyNodeId, - HandleDescriptor, - NodeDefinition, - SceneApi, - WallNode, +import { + type AnyNode, + type AnyNodeId, + findLevelAncestorId, + type HandleDescriptor, + type NodeDefinition, + type RoofSegmentNode, + type SceneApi, + type WallNode, } from '@pascal-app/core' -import type { FloorplanNodeExtension } from '@pascal-app/editor' import { - isManagedLeanToNode, - isManagedLeanToPost, - leanToDownspoutLayoutPatch, - leanToGutterLayoutPatch, - leanToPostLayoutPatch, - leanToRoofSegmentLayoutPatch, - managedLeanToPostIndex, - managedLeanToPostSide, - resolveLeanToPostBaseY, - resolveLeanToPostGutterSetback, -} from './assembly' + clearStructuralElevationGuide, + type FloorplanNodeExtension, + publishResolvedElevationGuide, +} from '@pascal-app/editor' import { buildLeanToExtensionFloorplan } from './floorplan' -import { leanToResizeAffordance } from './floorplan-affordances' +import { leanToResizeAffordance, leanToRotateAffordance } from './floorplan-affordances' import { leanToFloorplanMoveTarget } from './floorplan-move' import { buildLeanToExtensionGeometry, leanToExtensionGeometryKey } from './geometry' -import { resolveLeanToLayout } from './layout' +import { + leanToWallLocalPose, + resolveLeanToEdgeSnapTargets, + resolveLeanToHighEdgeHeightSnap, + resolveLeanToLayout, + resolveLeanToSpanResizeProposal, +} from './layout' +import { leanToManagedPreviewOverrides } from './managed-preview' import { leanToPaint } from './paint' import { deriveLeanToResizePatch, leanToExtensionParametrics } from './parametrics' import { applyLeanToRoofAttachment, resolveLeanToRoofAttachment } from './roof-attachment' @@ -32,7 +33,16 @@ import { leanToSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.25 const SPAN_HANDLE_OFFSET = 0.3 +const PITCH_HANDLE_OFFSET = 0.3 const ROOF_EDGE_SNAP_TOLERANCE = 0.3 +const MIN_PITCH = 1 +const MAX_PITCH = 45 + +function resolveConicalHost(node: LeanToExtensionNode, sceneApi: SceneApi): RoofSegmentNode | null { + if (!(node.hostKind === 'conical-roof' && node.parentId)) return null + const segment = sceneApi.get(node.parentId as AnyNodeId) + return segment?.type === 'roof-segment' && segment.roofType === 'conical' ? segment : null +} function resolveHostWall(node: LeanToExtensionNode, sceneApi: SceneApi): WallNode | null { if (!node.parentId) return null @@ -40,6 +50,120 @@ function resolveHostWall(node: LeanToExtensionNode, sceneApi: SceneApi): WallNod return wall?.type === 'wall' ? wall : null } +function resolveAdjacentHeightSnap( + node: LeanToExtensionNode, + newValue: number, + sceneApi: SceneApi, +) { + const wall = resolveHostWall(node, sceneApi) + if (!wall) return null + return resolveLeanToHighEdgeHeightSnap( + node, + newValue, + resolveLeanToEdgeSnapTargets(node, wall, sceneApi.nodes()), + ) +} + +function resolveHighEdgeConnectionSnap( + node: LeanToExtensionNode, + newValue: number, + sceneApi: SceneApi, +): number { + const wall = resolveHostWall(node, sceneApi) + if (!wall) return newValue + const attachment = resolveLeanToRoofAttachment( + { ...node, highEdgeHeight: newValue }, + wall, + sceneApi.nodes(), + ) + if (attachment && Math.abs(attachment.highEdgeHeight - newValue) <= ROOF_EDGE_SNAP_TOLERANCE) { + return attachment.highEdgeHeight + } + return resolveAdjacentHeightSnap(node, newValue, sceneApi)?.highEdgeHeight ?? newValue +} + +function publishAdjacentHeightGuide(node: LeanToExtensionNode, sceneApi: SceneApi): void { + const wall = resolveHostWall(node, sceneApi) + const nodes = sceneApi.nodes() + const match = wall ? resolveAdjacentHeightSnap(node, node.highEdgeHeight, sceneApi) : null + if (!(wall && match) || Math.abs(match.highEdgeHeight - node.highEdgeHeight) > 1e-4) { + clearStructuralElevationGuide(node.id) + return + } + + const pose = leanToWallLocalPose(wall, node, 0) + publishResolvedElevationGuide( + { + nodeId: node.id, + levelId: findLevelAncestorId(node.id as AnyNodeId, nodes), + anchor: [pose.position[0], pose.position[2]], + }, + { + id: `${match.target.nodeId ?? 'lean-to'}:high-edge`, + elevation: match.target.roofEdgeY, + anchor: match.target.anchor ?? [pose.position[0], pose.position[2]], + label: 'Neighbor shed edge', + }, + ) +} + +function highEdgeHeightPatch( + node: LeanToExtensionNode, + newValue: number, + sceneApi: SceneApi, +): Partial { + if (node.hostKind === 'slab-edge') { + return { + ...deriveLeanToResizePatch(node, { highEdgeHeight: newValue }), + hostHeightOffset: node.hostHeightOffset + newValue - node.highEdgeHeight, + connectionMode: 'manual', + } + } + const conicalHost = resolveConicalHost(node, sceneApi) + if (conicalHost) { + return { + ...deriveLeanToResizePatch(node, { highEdgeHeight: newValue }), + hostHeightOffset: newValue - conicalHost.wallHeight, + connectionMode: 'manual', + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } + } + const wall = resolveHostWall(node, sceneApi) + const attachment = wall + ? resolveLeanToRoofAttachment({ ...node, highEdgeHeight: newValue }, wall, sceneApi.nodes()) + : null + if (attachment && Math.abs(attachment.highEdgeHeight - newValue) <= 1e-4) { + 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, + } +} + function highEdgeHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', @@ -49,54 +173,12 @@ function highEdgeHeightHandle(): HandleDescriptor { 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, - } - }, + connectionSnap: resolveHighEdgeConnectionSnap, + apply: highEdgeHeightPatch, + previewOverrides: (node, newValue, sceneApi) => + leanToManagedPreviewOverrides(node, highEdgeHeightPatch(node, newValue, sceneApi), sceneApi), + onDrag: publishAdjacentHeightGuide, + onDragEnd: (node) => clearStructuralElevationGuide(node.id), placement: { position: (node) => [0, node.highEdgeHeight + HEIGHT_HANDLE_OFFSET, 0], }, @@ -104,90 +186,91 @@ function highEdgeHeightHandle(): HandleDescriptor { } } -function leanToManagedPreviewOverrides( +function pitchPatch( node: LeanToExtensionNode, - patch: Partial, - sceneApi: SceneApi, -): ReadonlyArray]> { - const next = { ...node, ...patch } as LeanToExtensionNode - const nodes = sceneApi.nodes() as Record - const entries: Array]> = [] - - const wall = next.parentId ? nodes[next.parentId as AnyNodeId] : undefined - for (const childId of next.children) { - const child = nodes[childId as AnyNodeId] - if (!child) continue - - if (child.type === 'column' && isManagedLeanToPost(child, next.id)) { - const index = managedLeanToPostIndex(child) - if (index === null) continue - const side = managedLeanToPostSide(child) - const baseY = - wall?.type === 'wall' ? resolveLeanToPostBaseY(next, wall, nodes, index, side) : 0 - const gutterSetback = side === 'low' ? resolveLeanToPostGutterSetback(next, child) : 0 - entries.push([ - child.id as AnyNodeId, - leanToPostLayoutPatch(next, index, baseY, gutterSetback, side) as Partial, - ]) - continue - } - - if (child.type !== 'roof' || !isManagedLeanToNode(child, next.id, 'roof')) continue - const segment = child.children - .map((id) => nodes[id as AnyNodeId]) - .find( - (candidate) => - candidate?.type === 'roof-segment' && - isManagedLeanToNode(candidate, next.id, 'roof-segment'), - ) - if (segment?.type !== 'roof-segment') continue - - const segmentPatch = leanToRoofSegmentLayoutPatch(next, nodes) - entries.push([segment.id as AnyNodeId, segmentPatch as Partial]) - - const nextSegment = { ...segment, ...segmentPatch } - const gutter = segment.children - .map((id) => nodes[id as AnyNodeId]) - .find( - (candidate) => - candidate?.type === 'gutter' && isManagedLeanToNode(candidate, next.id, 'gutter'), - ) - if (gutter?.type !== 'gutter') continue - const gutterPatch = leanToGutterLayoutPatch(nextSegment, next, gutter, nodes) - entries.push([gutter.id as AnyNodeId, gutterPatch as Partial]) - - const nextGutter = { ...gutter, ...gutterPatch } - const downspout = segment.children - .map((id) => nodes[id as AnyNodeId]) - .find( - (candidate) => - candidate?.type === 'downspout' && isManagedLeanToNode(candidate, next.id, 'downspout'), - ) - if (downspout?.type === 'downspout') { - entries.push([ - downspout.id as AnyNodeId, - leanToDownspoutLayoutPatch(nextSegment, nextGutter, next, downspout) as Partial, - ]) - } + lowEdgeHeight: number, +): Partial { + const pitch = Math.max( + MIN_PITCH, + Math.min( + MAX_PITCH, + (Math.atan2(node.highEdgeHeight - lowEdgeHeight, Math.max(0.001, node.projection)) * 180) / + Math.PI, + ), + ) + return { + pitch, + lowEdgeHeight: node.highEdgeHeight - node.projection * Math.tan((pitch * Math.PI) / 180), } +} - return entries +function pitchHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: (node) => resolveLeanToLayout({ ...node, pitch: MAX_PITCH }).lowEdgeHeight, + max: (node) => resolveLeanToLayout({ ...node, pitch: MIN_PITCH }).lowEdgeHeight, + gridSnap: true, + currentValue: (node) => resolveLeanToLayout(node).lowEdgeHeight, + apply: (node, lowEdgeHeight) => pitchPatch(node, lowEdgeHeight), + previewOverrides: (node, lowEdgeHeight, sceneApi) => + leanToManagedPreviewOverrides(node, pitchPatch(node, lowEdgeHeight), sceneApi), + placement: { + position: (node) => { + const layout = resolveLeanToLayout(node) + return [ + 0, + layout.lowEdgeHeight + HEIGHT_HANDLE_OFFSET, + node.projection + Math.max(0, node.lowOverhang) + PITCH_HANDLE_OFFSET, + ] + }, + }, + } } function spanPatch( node: LeanToExtensionNode, span: number, side: 'left' | 'right', + sceneApi?: SceneApi, ): Partial { + const wall = sceneApi ? resolveHostWall(node, sceneApi) : null + if (wall && sceneApi) { + const proposal = resolveLeanToSpanResizeProposal({ + node, + wall, + rawSpan: span, + side, + edgeSnapTargets: resolveLeanToEdgeSnapTargets(node, wall, sceneApi.nodes()), + tolerance: 1e-4, + }) + return { + span: proposal.span, + autoSpan: false, + position: proposal.position, + ...(proposal.target + ? { + highEdgeHeight: proposal.highEdgeHeight, + lowEdgeHeight: proposal.lowEdgeHeight, + pitch: proposal.pitch, + } + : {}), + } + } const localSign = side === 'right' ? 1 : -1 - const sign = Math.cos(node.rotation[1]) >= 0 ? localSign : -localSign + const centerShift = (localSign * (span - node.span)) / 2 + const cos = Math.cos(node.rotation[1]) + const sin = Math.sin(node.rotation[1]) + const deltaX = centerShift * cos + const deltaZ = -centerShift * sin return { span, autoSpan: false, position: [ - node.position[0] + (sign * (span - node.span)) / 2, + Math.abs(deltaX) < 1e-12 ? node.position[0] : node.position[0] + deltaX, node.position[1], - node.position[2], + Math.abs(deltaZ) < 1e-12 ? node.position[2] : node.position[2] + deltaZ, ], } } @@ -199,11 +282,28 @@ function spanHandle(side: 'left' | 'right'): HandleDescriptor { + const wall = resolveHostWall(node, sceneApi) + return wall + ? resolveLeanToSpanResizeProposal({ node, wall, rawSpan: 100, side, tolerance: 0 }).span + : 100 + }, currentValue: (node) => node.span, - apply: (node, span) => spanPatch(node, span, side), + connectionSnap: (node, span, sceneApi) => { + const wall = resolveHostWall(node, sceneApi) + if (!wall) return span + return resolveLeanToSpanResizeProposal({ + node, + wall, + rawSpan: span, + side, + edgeSnapTargets: resolveLeanToEdgeSnapTargets(node, wall, sceneApi.nodes()), + }).span + }, + apply: (node, span, sceneApi) => spanPatch(node, span, side, sceneApi), previewOverrides: (node, span, sceneApi) => - leanToManagedPreviewOverrides(node, spanPatch(node, span, side), sceneApi), + leanToManagedPreviewOverrides(node, spanPatch(node, span, side, sceneApi), sceneApi), + visible: (node) => node.hostKind !== 'conical-roof', placement: { position: (node) => { const layout = resolveLeanToLayout(node) @@ -219,7 +319,92 @@ function spanHandle(side: 'left' | 'right'): HandleDescriptor[] = [highEdgeHeightHandle()] +function circularRadiusPatch( + node: LeanToExtensionNode, + radius: number, +): Partial { + return { + span: 2 * Math.PI * radius, + autoSpan: true, + position: [0, node.position[1], radius], + spanArcCenterZ: -radius, + spanArcRadius: radius, + } +} + +function circularRadiusHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: 0.25, + max: 12.5, + gridSnap: true, + currentValue: (node) => node.spanArcRadius ?? node.span / (2 * Math.PI), + apply: (node, radius) => circularRadiusPatch(node, radius), + previewOverrides: (node, radius, sceneApi) => { + const patch = circularRadiusPatch(node, radius) + const host = resolveConicalHost(node, sceneApi) + const entries: Array]> = host + ? [[host.id as AnyNodeId, { width: radius * 2, depth: radius * 2 }]] + : [] + entries.push(...leanToManagedPreviewOverrides(node, patch, sceneApi)) + return entries + }, + commit: (node, patch, sceneApi) => { + const host = resolveConicalHost(node, sceneApi) + const radius = patch.spanArcRadius + if (!(host && typeof radius === 'number')) return + sceneApi.update(host.id as AnyNodeId, { width: radius * 2, depth: radius * 2 }) + }, + visible: (node, sceneApi) => resolveConicalHost(node, sceneApi) !== null, + placement: { + position: (node) => { + const layout = resolveLeanToLayout(node) + const radius = node.spanArcRadius ?? node.span / (2 * Math.PI) + return [ + sign * (radius + layout.projection + node.lowOverhang + SPAN_HANDLE_OFFSET), + layout.lowEdgeHeight + HEIGHT_HANDLE_OFFSET, + -radius, + ] + }, + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + measureLabel: 'Host radius', + } +} + +function freestandingRotationHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (node, delta) => ({ + rotation: [node.rotation[0], node.rotation[1] - delta, node.rotation[2]], + }), + visible: (node) => node.hostKind === 'freestanding', + placement: { + position: (node) => [ + node.span / 2 + SPAN_HANDLE_OFFSET, + resolveLeanToLayout(node).lowEdgeHeight + HEIGHT_HANDLE_OFFSET, + node.projection / 2, + ], + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: (node) => Math.hypot(node.span / 2, node.projection / 2) + 0.12, + y: (node) => resolveLeanToLayout(node).lowEdgeHeight + HEIGHT_HANDLE_OFFSET, + }, + } +} + +const leanToExtensionHandles: HandleDescriptor[] = [ + highEdgeHeightHandle(), + pitchHandle(), + freestandingRotationHandle(), +] leanToExtensionHandles.push({ kind: 'linear-resize', axis: 'z', @@ -240,10 +425,11 @@ leanToExtensionHandles.push({ measureLabel: 'Projection', }) leanToExtensionHandles.push(spanHandle('right'), spanHandle('left')) +leanToExtensionHandles.push(circularRadiusHandle('right'), circularRadiusHandle('left')) export const leanToExtensionDefinition: NodeDefinition = { kind: 'lean-to-extension', - schemaVersion: 7, + schemaVersion: 11, schema: LeanToExtensionNode, category: 'structure', snapProfile: 'structural', @@ -282,17 +468,22 @@ export const leanToExtensionDefinition: NodeDefinition import('./move-tool') }, preview: () => import('./preview'), tool: () => import('./tool'), toolHints: [ - { key: 'Left click', label: 'Attach lean-to extension to wall' }, + { key: 'Left click', label: 'Attach to wall/slab edge or place freestanding' }, + { key: 'R / T', label: 'Rotate freestanding' }, { 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.', + label: 'Lean-to Canopy', + description: + 'An attached or freestanding mono-pitch canopy with managed beams, posts, and drainage.', icon: { kind: 'url', src: '/icons/lean-to-extension.webp' }, paletteSection: 'structure', paletteGroup: 'roof-features', @@ -300,6 +491,6 @@ export const leanToExtensionDefinition: NodeDefinition = { start({ node, nodes, payload, initialPlanPoint, sceneApi }) { + if (!sceneApi) return { affectedIds: [], apply() {}, canCommit: () => false } const wall = node.parentId ? (nodes[node.parentId as AnyNodeId] as WallNode | undefined) : undefined - if (wall?.type !== 'wall' || !sceneApi) { - return { affectedIds: [], apply() {}, canCommit: () => false } - } const { dimension, side = 1 } = payload as ResizePayload const outwardSign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 let along: readonly [number, number] let outward: readonly [number, number] - // On a curved host the drag axes are the wall arc's tangent / normal at - // the lean-to's along-wall position, not the straight chord direction. - if (isCurvedWall(wall)) { + if (wall?.type === 'wall' && isCurvedWall(wall)) { const arcLength = Math.max(1e-6, getWallCurveLength(wall)) const t = Math.max(0, Math.min(1, node.position[0] / arcLength)) const frame = getWallCurveFrameAt(wall, t) along = [frame.tangent.x, frame.tangent.y] outward = [frame.normal.x * outwardSign, frame.normal.y * outwardSign] - } else { + } else if (wall?.type === 'wall') { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] const length = Math.max(1e-6, Math.hypot(dx, dz)) along = [dx / length, dz / length] outward = [-along[1] * outwardSign, along[0] * outwardSign] + } else { + const cos = Math.cos(node.rotation[1]) + const sin = Math.sin(node.rotation[1]) + along = [cos, -sin] + outward = [sin, cos] } const axis = dimension === 'projection' ? outward : along const initialAxis = initialPlanPoint[0] * axis[0] + initialPlanPoint[1] * axis[1] const initialValue = dimension === 'projection' ? node.projection : node.span - const initialPosition = node.position let lastPatch: Partial = {} return { affectedIds: [node.id as AnyNodeId], - apply({ planPoint }) { + apply({ planPoint, modifiers }) { const currentAxis = planPoint[0] * axis[0] + planPoint[1] * axis[1] const raw = initialValue + (currentAxis - initialAxis) * side - const step = getSegmentGridStep() + const step = !modifiers.altKey && isGridSnapActive() ? getSegmentGridStep() : 0 const value = Math.max(0.5, step > 0 ? snapScalar(raw, step) : raw) - lastPatch = - dimension === 'projection' - ? { projection: value, ...deriveLeanToResizePatch(node, { projection: value }) } - : { - span: value, - autoSpan: false, - position: [ - initialPosition[0] + (side * (value - initialValue)) / 2, - initialPosition[1], - initialPosition[2], - ], - } + if (dimension === 'projection') { + lastPatch = { + projection: value, + ...deriveLeanToResizePatch(node, { projection: value }), + } + } else if (wall?.type === 'wall') { + const proposal = resolveLeanToSpanResizeProposal({ + node, + wall, + rawSpan: value, + side: side > 0 ? 'right' : 'left', + edgeSnapTargets: modifiers.altKey + ? [] + : resolveLeanToEdgeSnapTargets(node, wall, nodes), + }) + lastPatch = { + span: proposal.span, + autoSpan: false, + position: proposal.position, + ...(proposal.target + ? { + highEdgeHeight: proposal.highEdgeHeight, + lowEdgeHeight: proposal.lowEdgeHeight, + pitch: proposal.pitch, + } + : {}), + } + } else { + const centerShift = (side * (value - node.span)) / 2 + const proposedPosition: LeanToExtensionNode['position'] = [ + node.position[0] + along[0] * centerShift, + node.position[1], + node.position[2] + along[1] * centerShift, + ] + const resolved = + node.hostKind === 'slab-edge' + ? moveLeanToAlongSlabEdge( + { ...node, autoSpan: false, span: value }, + [proposedPosition[0], proposedPosition[2]], + nodes as Record, + ) + : null + lastPatch = { + span: value, + autoSpan: false, + position: resolved?.position ?? proposedPosition, + ...(resolved ? { hostSlabEdgeT: resolved.hostSlabEdgeT } : {}), + } + } useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch) sceneApi.markDirty(node.id as AnyNodeId) }, @@ -77,3 +122,46 @@ export const leanToResizeAffordance: FloorplanAffordance = } }, } + +export const leanToRotateAffordance: FloorplanAffordance = { + start({ node, initialPlanPoint, sceneApi }) { + if (!(sceneApi && node.hostKind === 'freestanding')) { + return { affectedIds: [], apply() {}, canCommit: () => false } + } + const nodeId = node.id as AnyNodeId + const [centerX, centerZ] = resolveLeanToPlanCenter(node) + const rotationY = node.rotation[1] + const center: [number, number] = [ + node.position[0] + centerX * Math.cos(rotationY) + centerZ * Math.sin(rotationY), + node.position[2] - centerX * Math.sin(rotationY) + centerZ * Math.cos(rotationY), + ] + const initialAngle = Math.atan2( + initialPlanPoint[1] - center[1], + initialPlanPoint[0] - center[0], + ) + let lastRotation = node.rotation[1] + return { + affectedIds: [nodeId], + apply({ planPoint }) { + const delta = rotateAffordanceDelta({ + center, + initialAngle, + planPoint, + free: !isAngleSnapActive(), + }) + lastRotation = node.rotation[1] - delta + useLiveNodeOverrides.getState().set(nodeId, { + rotation: [node.rotation[0], lastRotation, node.rotation[2]], + }) + sceneApi.markDirty(nodeId) + }, + canCommit: () => true, + commit() { + useLiveNodeOverrides.getState().clear(nodeId) + sceneApi.update(nodeId, { + rotation: [node.rotation[0], lastRotation, node.rotation[2]], + }) + }, + } + }, +} diff --git a/packages/nodes/src/lean-to-extension/floorplan-move.test.ts b/packages/nodes/src/lean-to-extension/floorplan-move.test.ts new file mode 100644 index 0000000000..7db3060a4a --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan-move.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + LeanToExtensionNode, + LevelNode, + nodeRegistry, + registerNode, + SlabNode, + useLiveNodeOverrides, + WallNode, +} from '@pascal-app/core' +import { useEditor, useInteractionScope } from '@pascal-app/editor' +import { leanToExtensionDefinition } from './definition' +import { leanToFloorplanMoveTarget } from './floorplan-move' +import { resolveLeanToSlabEdgePlacement } from './placement' + +afterEach(() => { + useInteractionScope.getState().end() + useLiveNodeOverrides.getState().clearAll() +}) + +describe('lean-to floorplan move snapping', () => { + test('moves a freestanding canopy freely in plan', () => { + const moving = LeanToExtensionNode.parse({ + id: 'leanto_freestanding_move', + parentId: 'level_freestanding_move', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + position: [1, 0, 1], + }) + const nodes = { [moving.id]: moving } as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + markDirty: () => {}, + update: () => {}, + } as never + + const session = leanToFloorplanMoveTarget({ node: moving, nodes, sceneApi }) + session.apply({ planPoint: [3.8, 2.2], modifiers: { altKey: true, shiftKey: false } }) + + expect(useLiveNodeOverrides.getState().overrides.get(moving.id)?.position).toEqual([ + 3.8, 0, 0.8250000000000002, + ]) + expect(session.canCommit()).toBe(true) + }) + + test('moves a slab-attached canopy along its host edge', () => { + const building = BuildingNode.parse({ id: 'building_slab_move' }) + const ground = LevelNode.parse({ + id: 'level_slab_move_ground', + parentId: building.id, + level: 0, + height: 3, + }) + const first = LevelNode.parse({ + id: 'level_slab_move_first', + parentId: building.id, + level: 1, + height: 3, + }) + const slab = SlabNode.parse({ + id: 'slab_move_host', + parentId: first.id, + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + elevation: 0.05, + thickness: 0.2, + }) + const hostNodes = { + [building.id]: building, + [ground.id]: ground, + [first.id]: first, + [slab.id]: slab, + } as Record + const moving = resolveLeanToSlabEdgePlacement({ + activeLevelId: ground.id, + edgeIndex: 0, + edgeT: 0.5, + nodes: hostNodes, + slab, + })! + const nodes = { ...hostNodes, [moving.id]: moving } + const sceneApi = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + markDirty: () => {}, + update: () => {}, + } as never + + const session = leanToFloorplanMoveTarget({ node: moving, nodes, sceneApi }) + session.apply({ planPoint: [5, 1], modifiers: { altKey: true, shiftKey: false } }) + + const preview = useLiveNodeOverrides.getState().overrides.get(moving.id) + expect(preview?.position).toEqual([5, 0, 0]) + expect(preview?.hostSlabEdgeT).toBeCloseTo(5 / 6, 6) + expect(session.canCommit()).toBe(true) + }) + + test('connects a side edge while grid mode is active', () => { + if (!nodeRegistry.has(leanToExtensionDefinition.kind)) registerNode(leanToExtensionDefinition) + const wall = WallNode.parse({ + id: 'wall_move_snap', + parentId: 'level_move_snap', + start: [0, 0], + end: [5, 0], + }) + const adjacentWall = WallNode.parse({ + id: 'wall_move_snap_adjacent', + parentId: 'level_move_snap', + start: [4.87, 0], + end: [10, 0], + }) + const moving = LeanToExtensionNode.parse({ + id: 'leanto_move_snap', + parentId: wall.id, + position: [2, 0, 0.05], + span: 2, + leftOverhang: 0, + rightOverhang: 0, + }) + const adjacent = LeanToExtensionNode.parse({ + id: 'leanto_move_snap_adjacent', + parentId: adjacentWall.id, + position: [1, 0, 0.05], + span: 2, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wall.id]: wall, + [adjacentWall.id]: adjacentWall, + [moving.id]: moving, + [adjacent.id]: adjacent, + } as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + markDirty: () => {}, + update: () => {}, + } as never + + useEditor.setState((state) => ({ + gridSnapStep: 0.5, + snappingModeByContext: { ...state.snappingModeByContext, polygon: 'grid' }, + })) + useInteractionScope.getState().begin({ + kind: 'moving', + node: moving, + nodeId: moving.id, + nodeType: moving.type, + view: '2d', + }) + + const session = leanToFloorplanMoveTarget({ node: moving, nodes, sceneApi }) + session.apply({ planPoint: [3.8, 0], modifiers: { altKey: false, shiftKey: false } }) + + const preview = useLiveNodeOverrides.getState().overrides.get(moving.id) + expect(preview?.position?.[0]).toBeCloseTo(3.87) + }) + + test('keeps the raw side position while force-moving', () => { + const wall = WallNode.parse({ + id: 'wall_force_move', + parentId: 'level_force_move', + start: [0, 0], + end: [5, 0], + }) + const moving = LeanToExtensionNode.parse({ + id: 'leanto_force_move', + parentId: wall.id, + position: [2, 0, 0.05], + span: 2, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { [wall.id]: wall, [moving.id]: moving } as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + markDirty: () => {}, + update: () => {}, + } as never + + const session = leanToFloorplanMoveTarget({ node: moving, nodes, sceneApi }) + session.apply({ planPoint: [3.8, 0], modifiers: { altKey: true, shiftKey: false } }) + + const preview = useLiveNodeOverrides.getState().overrides.get(moving.id) + expect(preview?.position?.[0]).toBeCloseTo(3.8) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/floorplan-move.ts b/packages/nodes/src/lean-to-extension/floorplan-move.ts index 2f0af0f115..98856136ba 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-move.ts +++ b/packages/nodes/src/lean-to-extension/floorplan-move.ts @@ -8,8 +8,10 @@ import { useLiveNodeOverrides, type WallNode, } from '@pascal-app/core' -import { getSegmentGridStep } from '@pascal-app/editor' -import { resolveLeanToEdgeSnapTargets, resolveLeanToMoveCenterX } from './layout' +import { getSegmentGridStep, isGridSnapActive } from '@pascal-app/editor' +import { resolveLeanToEdgeSnapTargets, resolveLeanToMoveProposal } from './layout' +import { leanToManagedPreviewOverrides } from './managed-preview' +import { moveLeanToAlongSlabEdge, resolveLeanToPlanPosition } from './placement' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' // Arc-length along the wall centerline to the point on it nearest the @@ -53,11 +55,56 @@ export const leanToFloorplanMoveTarget: FloorplanMoveTarget const nodeId = node.id as AnyNodeId const wall = node.parentId ? (sceneApi?.get(node.parentId as AnyNodeId) as WallNode) : undefined let lastPatch: Partial | null = null + const previewIds = new Set( + sceneApi ? leanToManagedPreviewOverrides(node, {}, sceneApi).map(([id]) => id) : [], + ) return { - affectedIds: [nodeId], + affectedIds: [nodeId, ...previewIds], apply({ planPoint, modifiers }) { - if (wall?.type !== 'wall' || !sceneApi) return + if (!sceneApi) return + if (node.hostKind === 'freestanding') { + const step = !modifiers.altKey && isGridSnapActive() ? getSegmentGridStep() : 0 + const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) + const patch: Partial = { + position: resolveLeanToPlanPosition(node, [snap(planPoint[0]), snap(planPoint[1])]), + } + const previewEntries: ReadonlyArray]> = [ + [nodeId, patch as Partial], + ...leanToManagedPreviewOverrides(node, patch, sceneApi), + ] + useLiveNodeOverrides.getState().setMany(previewEntries) + for (const [id] of previewEntries) { + previewIds.add(id) + sceneApi.markDirty(id) + } + lastPatch = patch + return + } + if (node.hostKind === 'slab-edge') { + const resolved = moveLeanToAlongSlabEdge(node, planPoint, sceneApi.nodes()) + if (!resolved) return + const patch: Partial = { + hostSlabEdgeT: resolved.hostSlabEdgeT, + position: resolved.position, + rotation: resolved.rotation, + span: resolved.span, + highEdgeHeight: resolved.highEdgeHeight, + lowEdgeHeight: resolved.lowEdgeHeight, + } + const previewEntries: ReadonlyArray]> = [ + [nodeId, patch as Partial], + ...leanToManagedPreviewOverrides(node, patch, sceneApi), + ] + useLiveNodeOverrides.getState().setMany(previewEntries) + for (const [id] of previewEntries) { + previewIds.add(id) + sceneApi.markDirty(id) + } + lastPatch = patch + return + } + if (wall?.type !== 'wall') return const rawLocalX = isCurvedWall(wall) ? arcLengthUnderPoint(wall, planPoint) : (() => { @@ -68,39 +115,68 @@ export const leanToFloorplanMoveTarget: FloorplanMoveTarget ((planPoint[0] - wall.start[0]) * dx + (planPoint[1] - wall.start[1]) * dz) / length ) })() - const step = modifiers.altKey ? 0 : getSegmentGridStep() + const step = !modifiers.altKey && isGridSnapActive() ? getSegmentGridStep() : 0 const nodes = sceneApi.nodes() as Record + const proposal = resolveLeanToMoveProposal({ + node, + wall, + rawLocalX, + rawHighEdgeHeight: node.highEdgeHeight, + snapStep: step, + edgeSnapTargets: modifiers.altKey ? [] : resolveLeanToEdgeSnapTargets(node, wall, nodes), + }) const position: LeanToExtensionNode['position'] = [ - resolveLeanToMoveCenterX( - node, - wall, - rawLocalX, - step, - modifiers.altKey ? [] : resolveLeanToEdgeSnapTargets(node, wall, nodes), - ), + proposal.centerX, node.position[1], node.position[2], ] + const connectionOffset = + node.connectionMode === 'auto' + ? Math.max( + -1, + Math.min(1, node.connectionOffset + proposal.highEdgeHeight - node.highEdgeHeight), + ) + : node.connectionOffset const candidate = resolveLeanToEndAbutments( - { ...node, position, autoSpan: false }, + { + ...node, + position, + highEdgeHeight: proposal.highEdgeHeight, + lowEdgeHeight: proposal.lowEdgeHeight, + connectionOffset, + autoSpan: false, + }, wall, nodes, ) const patch: Partial = { position, + highEdgeHeight: proposal.highEdgeHeight, + lowEdgeHeight: proposal.lowEdgeHeight, + connectionOffset, 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 + const previewEntries: ReadonlyArray]> = [ + [nodeId, patch as Partial], + ...leanToManagedPreviewOverrides(node, patch, sceneApi), + ] + useLiveNodeOverrides.getState().setMany(previewEntries) + for (const [id] of previewEntries) { + previewIds.add(id) + sceneApi.markDirty(id) + } + lastPatch = + modifiers.altKey || leanToPlacementConflicts(candidate, wall, nodes).length === 0 + ? patch + : null }, canCommit: () => lastPatch !== null, commit() { if (!(lastPatch && sceneApi)) return - useLiveNodeOverrides.getState().clear(nodeId) + for (const id of [nodeId, ...previewIds]) useLiveNodeOverrides.getState().clear(id) 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 index 0030aa6014..ec908bbdc1 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx +++ b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx @@ -4,29 +4,35 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core' import { getWallCurveFrameAt, getWallCurveLength, isCurvedWall } from '@pascal-app/core' import { type FloorplanToolContext, + getSegmentGridStep, + isGridSnapActive, markToolCancelConsumed, triggerSFX, useEditor, useInteractionScope, } from '@pascal-app/editor' import { useCallback, useEffect, useRef, useState } from 'react' -import { findClosestWallInPlan } from '../shared/wall-attach-target' import { bendLocalPoint, isCurvedLeanTo } from './arc' import { createLeanToAssembly } from './assembly' +import { + type ConicalLeanToPlanHost, + findConicalLeanToHostInPlan, + isConicalLeanToHostOccupied, +} from './conical-host' import { leanToFacetCount } from './geometry' -import { resolveLeanToSpanArc, resolveLeanToWallPlacement } from './layout' -import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' +import { resolveLeanToSpanArc } from './layout' import { - applyLeanToAvailableWallSpan, - applyLeanToRoofAttachment, - applyLeanToWallAutoSpan, - clearLeanToRoofAttachment, - resolveLeanToHostRoof, - resolveLeanToRoofAttachment, -} from './roof-attachment' -import type { LeanToExtensionNode } from './schema' + type LeanToPlanPlacementTarget, + nextLeanToPlacementRotation, + resolveLeanToCommitTarget, + resolveLeanToPlanPlacement, +} from './placement' +import { resolveLeanToHostRoof } from './roof-attachment' type PlanPoint = [number, number] +type PlanTarget = LeanToPlanPlacementTarget & { + conicalHost?: ConicalLeanToPlanHost +} function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number): PlanPoint | null { const matrix = group.getScreenCTM() @@ -42,8 +48,9 @@ const FloorplanLeanToExtensionTool = ({ selectNode, }: FloorplanToolContext) => { const groupRef = useRef(null) - const targetRef = useRef(null) - const [target, setTarget] = useState(null) + const targetRef = useRef(null) + const rotationRef = useRef(0) + const [target, setTarget] = useState(null) const clearTarget = useCallback(() => { targetRef.current = null @@ -56,6 +63,8 @@ const FloorplanLeanToExtensionTool = ({ const svg = group?.ownerSVGElement if (!(group && svg)) return useInteractionScope.getState().begin({ kind: 'drafting', tool: 'lean-to-extension' }) + rotationRef.current = 0 + let lastFreestandingEvent: PointerEvent | null = null const consume = (event: Event) => { event.preventDefault() @@ -65,31 +74,31 @@ const FloorplanLeanToExtensionTool = ({ const resolveEvent = (event: MouseEvent | PointerEvent) => { const point = clientToPlanPoint(group, event.clientX, event.clientY) if (!point) return null - const hit = findClosestWallInPlan( - point, - sceneApi.nodes() as Record, - activeLevelId, - ) - if (!hit) return null - const wallPlacement = resolveLeanToWallPlacement(hit.wall, hit.localX, hit.side) - if (!wallPlacement) return null const nodes = sceneApi.nodes() as Record - const attachment = resolveLeanToRoofAttachment(wallPlacement, hit.wall, nodes) - const autoSpannedNode = attachment - ? applyLeanToRoofAttachment(wallPlacement, attachment) - : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), hit.wall) - const attachedNode = applyLeanToAvailableWallSpan( - autoSpannedNode, - hit.wall, + const conicalHost = findConicalLeanToHostInPlan(point, nodes, activeLevelId, { + includeOccupied: true, + }) + if (conicalHost) { + return { + node: conicalHost.node, + valid: !isConicalLeanToHostOccupied(conicalHost.segment.id, nodes), + conicalHost, + } + } + const step = !event.altKey && isGridSnapActive() ? getSegmentGridStep() : 0 + const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) + return resolveLeanToPlanPlacement({ + activeLevelId, + freestandingPoint: [snap(point[0]), snap(point[1])], + freestandingRotationY: rotationRef.current, nodes, - wallPlacement.position[0], - ) - const node = resolveLeanToEndAbutments(attachedNode, hit.wall, nodes) - return leanToPlacementConflicts(node, hit.wall, nodes).length === 0 ? node : null + point, + }) } const update = (event: PointerEvent) => { consume(event) const node = resolveEvent(event) + lastFreestandingEvent = node?.node.hostKind === 'freestanding' ? event : null targetRef.current = node setTarget(node) } @@ -99,8 +108,9 @@ const FloorplanLeanToExtensionTool = ({ const commit = (event: MouseEvent) => { if (event.button !== 0) return consume(event) - const node = resolveEvent(event) ?? targetRef.current - if (!node) return + const resolved = resolveLeanToCommitTarget(targetRef.current, resolveEvent(event)) + if (!resolved?.valid) return + const { node } = resolved const nodes = sceneApi.nodes() as Record const assembly = createLeanToAssembly(node, resolveLeanToHostRoof(node, nodes), nodes) sceneApi.createMany?.([ @@ -114,25 +124,53 @@ const FloorplanLeanToExtensionTool = ({ triggerSFX('sfx:structure-build') if (useEditor.getState().getContinuation('point') !== 'repeat') finishTool() } - const cancel = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopImmediatePropagation() + markToolCancelConsumed() + finishTool() + return + } + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return + } + if (!lastFreestandingEvent) return + + const nextRotation = nextLeanToPlacementRotation( + rotationRef.current, + event.key, + event.metaKey || event.ctrlKey, + ) + if (nextRotation === rotationRef.current) return + event.preventDefault() - event.stopImmediatePropagation() - markToolCancelConsumed() - finishTool() + rotationRef.current = nextRotation + triggerSFX('sfx:item-rotate') + const resolved = resolveEvent(lastFreestandingEvent) + targetRef.current = resolved + setTarget(resolved) + } + const onPointerLeave = (event: PointerEvent) => { + lastFreestandingEvent = null + clearTarget() } svg.addEventListener('pointerdown', onPointerDown, true) svg.addEventListener('pointermove', update, true) - svg.addEventListener('pointerleave', clearTarget, true) + svg.addEventListener('pointerleave', onPointerLeave, true) svg.addEventListener('click', commit, true) - window.addEventListener('keydown', cancel, true) + window.addEventListener('keydown', onKeyDown, true) return () => { svg.removeEventListener('pointerdown', onPointerDown, true) svg.removeEventListener('pointermove', update, true) - svg.removeEventListener('pointerleave', clearTarget, true) + svg.removeEventListener('pointerleave', onPointerLeave, true) svg.removeEventListener('click', commit, true) - window.removeEventListener('keydown', cancel, true) + window.removeEventListener('keydown', onKeyDown, true) clearTarget() useInteractionScope .getState() @@ -141,15 +179,79 @@ const FloorplanLeanToExtensionTool = ({ }, [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 + if (target?.conicalHost) { + const { center, segment } = target.conicalHost + const innerRadius = Math.max(0.01, segment.width / 2 - target.node.highOverhang) + const outerRadius = segment.width / 2 + target.node.projection + target.node.lowOverhang + const points: [number, number][] = [] + const facets = leanToFacetCount(target.node) + for (let index = 0; index <= facets; index++) { + const angle = (index / facets) * Math.PI * 2 + points.push([ + center[0] + Math.sin(angle) * innerRadius, + center[1] + Math.cos(angle) * innerRadius, + ]) + } + for (let index = facets; index >= 0; index--) { + const angle = (index / facets) * Math.PI * 2 + points.push([ + center[0] + Math.sin(angle) * outerRadius, + center[1] + Math.cos(angle) * outerRadius, + ]) + } + return ( + + point.join(',')).join(' ')} + stroke={target.valid ? '#0ea5e9' : '#ef4444'} + strokeDasharray="6 4" + strokeWidth={2} + vectorEffect="non-scaling-stroke" + /> + + ) + } + + const node = target?.node + const wall = node?.parentId ? sceneApi.get(node.parentId as AnyNodeId) : null + if (node && (node.hostKind === 'slab-edge' || node.hostKind === 'freestanding')) { + const cos = Math.cos(node.rotation[1]) + const sin = Math.sin(node.rotation[1]) + const toWorld = (localX: number, localZ: number): [number, number] => [ + node.position[0] + localX * cos + localZ * sin, + node.position[2] - localX * sin + localZ * cos, + ] + const points = [ + toWorld(-(node.span / 2 + node.leftOverhang), -node.highOverhang), + toWorld(node.span / 2 + node.rightOverhang, -node.highOverhang), + toWorld(node.span / 2 + node.rightOverhang, node.projection + node.lowOverhang), + toWorld(-(node.span / 2 + node.leftOverhang), node.projection + node.lowOverhang), + ] + return ( + + point.join(',')).join(' ')} + stroke={target.valid ? '#0ea5e9' : '#ef4444'} + strokeDasharray="6 4" + strokeWidth={2} + vectorEffect="non-scaling-stroke" + /> + + ) + } + if (!(node && wall?.type === 'wall')) return - const sign = Math.cos(target.rotation[1]) >= 0 ? 1 : -1 + const sign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 // Recompute the local arc from the final placed span/position so the preview // footprint bends the same way reconciliation will store it. - const spanArc = resolveLeanToSpanArc(wall, target) + const spanArc = resolveLeanToSpanArc(wall, node) const previewNode = { - ...target, + ...node, spanArcCenterZ: spanArc?.centerZ, spanArcRadius: spanArc?.radius, } @@ -163,14 +265,14 @@ const FloorplanLeanToExtensionTool = ({ let perpZ: number if (curved) { const arcLength = getWallCurveLength(wall) - const t = Math.max(0, Math.min(1, arcLength > 1e-6 ? target.position[0] / arcLength : 0)) + const t = Math.max(0, Math.min(1, arcLength > 1e-6 ? node.position[0] / arcLength : 0)) const frame = getWallCurveFrameAt(wall, t) alongX = frame.tangent.x alongZ = frame.tangent.y perpX = frame.normal.x perpZ = frame.normal.y - originX = frame.point.x + perpX * target.position[2] - originZ = frame.point.y + perpZ * target.position[2] + originX = frame.point.x + perpX * node.position[2] + originZ = frame.point.y + perpZ * node.position[2] } else { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] @@ -179,8 +281,8 @@ const FloorplanLeanToExtensionTool = ({ alongZ = dz / length perpX = -alongZ perpZ = alongX - originX = wall.start[0] + alongX * target.position[0] + perpX * target.position[2] - originZ = wall.start[1] + alongZ * target.position[0] + perpZ * target.position[2] + originX = wall.start[0] + alongX * node.position[0] + perpX * node.position[2] + originZ = wall.start[1] + alongZ * node.position[0] + perpZ * node.position[2] } const localAlongX = alongX * sign const localAlongZ = alongZ * sign @@ -199,10 +301,10 @@ const FloorplanLeanToExtensionTool = ({ originZ + localAlongZ * localX + outZ * localZ, ] } - const left = target.span / 2 + target.leftOverhang - const right = target.span / 2 + target.rightOverhang - const high = target.highOverhang - const low = target.projection + target.lowOverhang + const left = node.span / 2 + node.leftOverhang + const right = node.span / 2 + node.rightOverhang + const high = node.highOverhang + const low = node.projection + node.lowOverhang const facets = curved ? leanToFacetCount(previewNode) : 1 const highEdge: [number, number][] = [] const lowEdge: [number, number][] = [] @@ -216,10 +318,10 @@ const FloorplanLeanToExtensionTool = ({ return ( point.join(',')).join(' ')} - stroke="#0ea5e9" + stroke={target.valid ? '#0ea5e9' : '#ef4444'} strokeDasharray="6 4" strokeWidth={2} vectorEffect="non-scaling-stroke" diff --git a/packages/nodes/src/lean-to-extension/floorplan.test.ts b/packages/nodes/src/lean-to-extension/floorplan.test.ts index 80c1073851..bc5ab7bec1 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.test.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.test.ts @@ -3,12 +3,50 @@ import { type GeometryContext, getWallCurveFrameAt, getWallCurveLength, + LeanToExtensionNode, + LevelNode, + RoofNode, + RoofSegmentNode, WallNode, } from '@pascal-app/core' +import { resolveConicalLeanToPlacement } from './conical-host' import { buildLeanToExtensionFloorplan } from './floorplan' import { resolveLeanToWallPlacement } from './layout' describe('curved lean-to floorplan', () => { + test('draws a freestanding canopy in its level plan frame', () => { + const level = LevelNode.parse({ id: 'level_free_canopy', level: 0 }) + const node = LeanToExtensionNode.parse({ + parentId: level.id, + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + position: [10, 0, 20], + rotation: [0, Math.PI / 2, 0], + span: 2, + projection: 1, + highOverhang: 0, + lowOverhang: 0, + leftOverhang: 0, + rightOverhang: 0, + }) + const geometry = buildLeanToExtensionFloorplan(node, { + children: [], + parent: level, + resolve: () => undefined, + siblings: [], + } as GeometryContext) + + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + const roof = geometry.children.find((child) => child.kind === 'polygon') + expect(roof?.kind).toBe('polygon') + if (roof?.kind !== 'polygon') return + expect(Math.min(...roof.points.map((point) => point[0]))).toBeCloseTo(10, 6) + expect(Math.max(...roof.points.map((point) => point[0]))).toBeCloseTo(11, 6) + expect(Math.min(...roof.points.map((point) => point[1]))).toBeCloseTo(19, 6) + expect(Math.max(...roof.points.map((point) => point[1]))).toBeCloseTo(21, 6) + }) + test('matches the committed back-side frame direction', () => { const wall = WallNode.parse({ start: [0, 0], end: [6, 0], curveOffset: 1, thickness: 0.2 }) const wallLength = getWallCurveLength(wall) @@ -37,4 +75,40 @@ describe('curved lean-to floorplan', () => { expect(roof.points[0]?.[0]).toBeCloseTo(frame.point.x + frame.normal.x * node.position[2], 3) expect(roof.points[0]?.[1]).toBeCloseTo(frame.point.y + frame.normal.y * node.position[2], 3) }) + + test('draws a closed canopy around a conical host', () => { + const roof = RoofNode.parse({ + id: 'roof_conical_floorplan', + position: [2, 0, 3], + children: ['rseg_conical_floorplan'], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_conical_floorplan', + parentId: roof.id, + roofType: 'conical', + position: [1, 0, 0], + width: 8, + depth: 8, + wallHeight: 3, + }) + const node = resolveConicalLeanToPlacement(segment)! + const geometry = buildLeanToExtensionFloorplan(node, { + children: [], + parent: segment, + resolve: (id) => (id === roof.id ? roof : undefined), + siblings: [], + } as GeometryContext) + + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + const roofBand = geometry.children.find((child) => child.kind === 'polygon') + expect(roofBand?.kind).toBe('polygon') + if (roofBand?.kind !== 'polygon') return + const xs = roofBand.points.map((point) => point[0]) + const zs = roofBand.points.map((point) => point[1]) + expect(Math.min(...xs)).toBeCloseTo(3 - 6.75, 2) + expect(Math.max(...xs)).toBeCloseTo(3 + 6.75, 2) + expect(Math.min(...zs)).toBeCloseTo(3 - 6.75, 2) + expect(Math.max(...zs)).toBeCloseTo(3 + 6.75, 2) + }) }) diff --git a/packages/nodes/src/lean-to-extension/floorplan.ts b/packages/nodes/src/lean-to-extension/floorplan.ts index b56f4c814f..bbe4e34ef6 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.ts @@ -1,4 +1,5 @@ import { + type AnyNodeId, type FloorplanGeometry, type FloorplanPoint, type GeometryContext, @@ -6,16 +7,221 @@ import { getWallCurveLength, isCurvedWall, type LeanToExtensionNode, + type RoofNode, + type RoofSegmentNode, type WallNode, } from '@pascal-app/core' import { bendLocalPoint, isCurvedLeanTo } from './arc' import { leanToFacetCount } from './geometry' import { resolveLeanToLayout } from './layout' +import { isLeanToPostOmitted } from './post-omissions' + +function conicalSegmentPlanPose( + segment: RoofSegmentNode, + ctx: GeometryContext, +): { center: FloorplanPoint; rotationY: number } { + const chain: (RoofNode | RoofSegmentNode)[] = [segment] + let parentId = segment.parentId + while (parentId) { + const parent = ctx.resolve(parentId as AnyNodeId) + if (parent?.type !== 'roof' && parent?.type !== 'roof-segment') break + chain.push(parent) + parentId = parent.parentId + } + + let x = 0 + let z = 0 + let rotationY = 0 + for (const node of chain.reverse()) { + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + x += node.position[0] * cos + node.position[2] * sin + z += -node.position[0] * sin + node.position[2] * cos + rotationY += node.rotation + } + return { center: [x, z], rotationY } +} + +function buildConicalLeanToFloorplan( + node: LeanToExtensionNode, + segment: RoofSegmentNode, + ctx: GeometryContext, +): FloorplanGeometry { + const layout = resolveLeanToLayout(node) + const pose = conicalSegmentPlanPose(segment, ctx) + const rotationY = pose.rotationY + node.rotation[1] + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + const toWorld = (localX: number, localZ: number): FloorplanPoint => { + const bent = bendLocalPoint(node, localX, localZ) + const x = node.position[0] + bent.x + const z = node.position[2] + bent.y + return [pose.center[0] + x * cos + z * sin, pose.center[1] - x * sin + z * cos] + } + const facets = leanToFacetCount(node) + const highEdge: FloorplanPoint[] = [] + const lowEdge: FloorplanPoint[] = [] + for (let index = 0; index <= facets; index++) { + const localX = -layout.span / 2 + (layout.span * index) / facets + highEdge.push(toWorld(localX, -node.highOverhang)) + lowEdge.push(toWorld(localX, layout.projection + node.lowOverhang)) + } + + const selected = ctx.viewState?.selected ?? false + const stroke = selected ? '#f97316' : '#475569' + const children: FloorplanGeometry[] = [ + { + kind: 'polygon', + points: [...highEdge, ...lowEdge.reverse()], + fill: selected ? '#ffedd5' : '#e2e8f0', + fillOpacity: 0.65, + stroke, + strokeWidth: selected ? 2 : 1.25, + vectorEffect: 'non-scaling-stroke', + }, + { + kind: 'polyline', + points: Array.from({ length: facets + 1 }, (_, index) => { + const localX = -layout.span / 2 + (layout.span * index) / facets + return toWorld(localX, layout.beamZ) + }), + stroke, + strokeWidth: selected ? 3 : 2, + vectorEffect: 'non-scaling-stroke', + }, + ] + for (const [index, x] of layout.postXs.entries()) { + if (isLeanToPostOmitted(node, 'low', index)) continue + const [postX, postZ] = toWorld(x, layout.beamZ) + children.push({ + kind: 'rect', + x: postX - node.postWidth / 2, + y: postZ - node.postDepth / 2, + width: node.postWidth, + height: node.postDepth, + fill: stroke, + stroke, + strokeWidth: 1, + vectorEffect: 'non-scaling-stroke', + }) + } + if (selected) { + const point = toWorld(0, layout.roofRun + 0.12) + children.push({ + kind: 'move-arrow', + point, + angle: Math.atan2(Math.cos(rotationY), Math.sin(rotationY)), + affordance: 'lean-to-resize', + payload: { dimension: 'projection' }, + }) + } + return { kind: 'group', children } +} + +function buildLevelLeanToFloorplan( + node: LeanToExtensionNode, + ctx: GeometryContext, +): FloorplanGeometry { + const layout = resolveLeanToLayout(node) + const rotationY = node.rotation[1] + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + const toWorld = (localX: number, localZ: number): FloorplanPoint => [ + node.position[0] + localX * cos + localZ * sin, + node.position[2] - localX * sin + localZ * cos, + ] + const left = layout.span / 2 + node.leftOverhang + const right = layout.span / 2 + node.rightOverhang + const high = node.highOverhang + const low = layout.projection + node.lowOverhang + const points: FloorplanPoint[] = [ + toWorld(-left, -high), + toWorld(right, -high), + toWorld(right, low), + toWorld(-left, low), + ] + 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: 'polyline', + points: [ + toWorld(-layout.beamSpan / 2, layout.beamZ), + toWorld(layout.beamSpan / 2, layout.beamZ), + ], + stroke, + strokeWidth: selected ? 3 : 2, + vectorEffect: 'non-scaling-stroke', + }, + ] + const addPostRow = (localZ: number, side: 'low' | 'high') => { + for (const [index, x] of layout.postXs.entries()) { + if (isLeanToPostOmitted(node, side, index)) continue + const [postX, postZ] = toWorld(x, localZ) + 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', + }) + } + } + addPostRow(layout.beamZ, 'low') + if (node.highSideMode === 'independent-high-beam') addPostRow(0, 'high') + if (selected) { + children.push({ + kind: 'move-arrow', + point: toWorld(0, layout.roofRun + 0.12), + angle: Math.atan2(Math.cos(rotationY), Math.sin(rotationY)), + affordance: 'lean-to-resize', + payload: { dimension: 'projection' }, + }) + if (node.hostKind === 'freestanding') { + const point = toWorld(right + 0.25, low + 0.25) + const center = toWorld(layout.roofCenterX, layout.roofCenterZ) + children.push({ + kind: 'rotate-arrow', + point, + angle: Math.atan2(point[1] - center[1], point[0] - center[0]), + affordance: 'lean-to-rotate', + pivot: center, + }) + } + } + return { kind: 'group', children } +} export function buildLeanToExtensionFloorplan( node: LeanToExtensionNode, ctx: GeometryContext, ): FloorplanGeometry | null { + if ( + ctx.parent?.type === 'roof-segment' && + ctx.parent.roofType === 'conical' && + node.hostKind === 'conical-roof' + ) { + return buildConicalLeanToFloorplan(node, ctx.parent, ctx) + } + if ( + ctx.parent?.type === 'level' && + (node.hostKind === 'slab-edge' || node.hostKind === 'freestanding') + ) { + return buildLevelLeanToFloorplan(node, ctx) + } const wall = ctx.parent as WallNode | null if (wall?.type !== 'wall') return null @@ -118,7 +324,8 @@ export function buildLeanToExtensionFloorplan( vectorEffect: 'non-scaling-stroke', }) - for (const x of layout.postXs) { + for (const [index, x] of layout.postXs.entries()) { + if (isLeanToPostOmitted(node, 'low', index)) continue const [postX, postZ] = toWorld(x, layout.beamZ) children.push({ kind: 'rect', diff --git a/packages/nodes/src/lean-to-extension/index.ts b/packages/nodes/src/lean-to-extension/index.ts index a808af789f..fb815b9ad1 100644 --- a/packages/nodes/src/lean-to-extension/index.ts +++ b/packages/nodes/src/lean-to-extension/index.ts @@ -7,4 +7,11 @@ export { resolveLeanToLayout, resolveLeanToWallPlacement, } from './layout' +export { + findLeanToSlabEdgePlacement, + moveLeanToAlongSlabEdge, + reconcileLeanToSlabEdgePlacement, + resolveLeanToFreestandingPlacement, + resolveLeanToSlabEdgePlacement, +} from './placement' 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 index 9530c2979a..a775c576aa 100644 --- a/packages/nodes/src/lean-to-extension/layout.test.ts +++ b/packages/nodes/src/lean-to-extension/layout.test.ts @@ -12,7 +12,9 @@ import { resolveLeanToEdgeSnapTargets, resolveLeanToLayout, resolveLeanToMoveCenterX, + resolveLeanToMoveProposal, resolveLeanToParentPose, + resolveLeanToSpanResizeProposal, resolveLeanToWallPlacement, resolveLeanToWallSurfaceHit, } from './layout' @@ -201,7 +203,7 @@ describe('lean-to wall placement', () => { const adjacent = LeanToExtensionNode.parse({ id: 'leanto_right', parentId: adjacentWall.id, - position: [1.2, 0, 0.05], + position: [1, 0, 0.05], span: 2, leftOverhang: 0, rightOverhang: 0, @@ -217,13 +219,197 @@ describe('lean-to wall placement', () => { resolveLeanToMoveCenterX( moving, wall, - 4.1, + 3.9, 0, resolveLeanToEdgeSnapTargets(moving, wall, nodes), ), ).toBe(4) }) + test('aligns the moving roof height when its edge magnetically snaps to a neighbor', () => { + const wall = WallNode.parse({ + id: 'wall_left', + parentId: 'level_test', + start: [0, 0], + end: [5, 0], + }) + const adjacentWall = WallNode.parse({ + id: 'wall_right', + parentId: 'level_test', + start: [5, 0], + end: [10, 0], + }) + const moving = LeanToExtensionNode.parse({ + id: 'leanto_left', + parentId: wall.id, + position: [2, 0, 0.05], + span: 2, + highEdgeHeight: 2.8, + leftOverhang: 0, + rightOverhang: 0, + }) + const adjacent = LeanToExtensionNode.parse({ + id: 'leanto_right', + parentId: adjacentWall.id, + position: [1, 0, 0.05], + span: 2, + highEdgeHeight: 3.4, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wall.id]: wall, + [adjacentWall.id]: adjacentWall, + [moving.id]: moving, + [adjacent.id]: adjacent, + } as Record + + const proposal = resolveLeanToMoveProposal({ + node: moving, + wall, + rawLocalX: 3.9, + rawHighEdgeHeight: 3, + edgeSnapTargets: resolveLeanToEdgeSnapTargets(moving, wall, nodes), + }) + + expect(proposal.centerX).toBe(4) + expect(proposal.highEdgeHeight).toBe(3.4) + expect(proposal.lowEdgeHeight - moving.lowEdgeHeight).toBeCloseTo(0.6) + }) + + test('stops a span resize at the host wall end', () => { + const wall = WallNode.parse({ start: [0, 0], end: [10, 0] }) + const leanTo = LeanToExtensionNode.parse({ + parentId: wall.id, + position: [4, 0, 0.05], + span: 4, + leftOverhang: 0.2, + rightOverhang: 0.2, + }) + + const proposal = resolveLeanToSpanResizeProposal({ + node: leanTo, + wall, + rawSpan: 7.65, + side: 'right', + }) + + expect(proposal.span).toBeCloseTo(7.8) + expect(proposal.position[0]).toBeCloseTo(5.9) + expect(proposal.position[0] + proposal.span / 2 + leanTo.rightOverhang).toBeCloseTo(10) + }) + + test('fits a resized span to its neighbor and adopts the same roof plane', () => { + const wall = WallNode.parse({ + id: 'wall_span_left', + parentId: 'level_test', + start: [0, 0], + end: [5, 0], + }) + const adjacentWall = WallNode.parse({ + id: 'wall_span_right', + parentId: 'level_test', + start: [5, 0], + end: [10, 0], + }) + const moving = LeanToExtensionNode.parse({ + id: 'leanto_span_left', + parentId: wall.id, + position: [2, 0, 0.05], + span: 2, + highEdgeHeight: 2.8, + pitch: 8, + leftOverhang: 0, + rightOverhang: 0, + }) + const adjacent = LeanToExtensionNode.parse({ + id: 'leanto_span_right', + parentId: adjacentWall.id, + position: [1, 0, 0.05], + span: 2, + highEdgeHeight: 3.4, + pitch: 12, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wall.id]: wall, + [adjacentWall.id]: adjacentWall, + [moving.id]: moving, + [adjacent.id]: adjacent, + } as Record + + const proposal = resolveLeanToSpanResizeProposal({ + node: moving, + wall, + rawSpan: 3.85, + side: 'right', + edgeSnapTargets: resolveLeanToEdgeSnapTargets(moving, wall, nodes), + }) + + expect(proposal.span).toBe(4) + expect(proposal.position[0]).toBe(3) + expect(proposal.highEdgeHeight).toBe(3.4) + expect(proposal.pitch).toBe(12) + expect(proposal.lowEdgeHeight).toBeCloseTo( + proposal.highEdgeHeight - moving.projection * Math.tan((proposal.pitch * Math.PI) / 180), + ) + expect(proposal.target?.nodeId).toBe(adjacent.id) + }) + + test('aligns a straight span with a curved roof at their tangent wall end', () => { + const curvedWall = WallNode.parse({ + id: 'wall_resize_curved', + parentId: 'level_test', + start: [0, 0], + end: [6, 0], + curveOffset: 1, + }) + const straightWall = WallNode.parse({ + id: 'wall_resize_tangent', + parentId: 'level_test', + start: [6, 0], + end: [10.8, 3.6], + }) + const curvedLength = getWallCurveLength(curvedWall) + const straightLength = getWallCurveLength(straightWall) + const curved = LeanToExtensionNode.parse({ + id: 'leanto_resize_curved', + parentId: curvedWall.id, + position: [curvedLength / 2, 0, 0.05], + span: curvedLength - 0.3, + highEdgeHeight: 3.5, + pitch: 14, + }) + const straight = LeanToExtensionNode.parse({ + id: 'leanto_resize_tangent', + parentId: straightWall.id, + position: [3.75, 0, 0.05], + span: 4.2, + highEdgeHeight: 2.8, + pitch: 8, + }) + const nodes = { + [curvedWall.id]: curvedWall, + [straightWall.id]: straightWall, + [curved.id]: curved, + [straight.id]: straight, + } as Record + + const proposal = resolveLeanToSpanResizeProposal({ + node: straight, + wall: straightWall, + rawSpan: straightLength - 0.45, + side: 'left', + edgeSnapTargets: resolveLeanToEdgeSnapTargets(straight, straightWall, nodes), + }) + + expect(proposal.position[0] - proposal.span / 2 - straight.leftOverhang).toBeCloseTo(0) + expect(proposal.highEdgeHeight).toBe(3.5) + expect(proposal.pitch).toBe(14) + expect(proposal.target?.nodeId).toBe(curved.id) + }) + test('keeps existing roof data unchanged when parsed with the extended node union', () => { const existingRoof = RoofNode.parse({ children: [], diff --git a/packages/nodes/src/lean-to-extension/layout.ts b/packages/nodes/src/lean-to-extension/layout.ts index 6b274d190e..d39ba6b42f 100644 --- a/packages/nodes/src/lean-to-extension/layout.ts +++ b/packages/nodes/src/lean-to-extension/layout.ts @@ -10,12 +10,15 @@ import { type WallNode, } from '@pascal-app/core' import { EAVE_TUCK_INWARD } from '../gutter/eave-snap' +import { resolveWallAttachmentAtPlanPoint } from '../shared/wall-attach-target' import { type LeanToArcFrame, leanToArcFrameAtLocalX } from './arc' +import { isClosedLoopLeanTo } from './conical-host' export const MIN_LEAN_TO_POST_HEIGHT = 0.2 export const MIN_LEAN_TO_WALL_LENGTH = 0.6 export const LEAN_TO_EXTENSION_GEOMETRY_REVISION = 8 -const LEAN_TO_EDGE_SNAP_TOLERANCE = 0.25 +export const LEAN_TO_EDGE_SNAP_TOLERANCE = 0.25 +export const LEAN_TO_HEIGHT_SNAP_TOLERANCE = 0.15 const CURVED_INNER_EDGE_CLEARANCE = 0.15 export type LeanToLayout = { @@ -59,27 +62,21 @@ export function resolveLeanToWallSurfaceHit( if (!normal) return null if (!isCurvedWall(wall)) { if (Math.abs(normal[2]) <= 0.7) return null - return { localX: localPosition[0], side: normal[2] >= 0 ? 'front' : 'back' } + } else if (Math.abs(normal[1]) > 0.7) { + return null } - if (Math.abs(normal[1]) > 0.7) return null - const arc = getWallArcData(wall) - if (!arc) return null const chord = getWallChordFrame(wall) - const point = { - x: chord.start.x + chord.tangent.x * localPosition[0] + chord.normal.x * localPosition[2], - y: chord.start.y + chord.tangent.y * localPosition[0] + chord.normal.y * localPosition[2], - } - const angle = Math.atan2(point.y - arc.center.y, point.x - arc.center.x) - let directedAngle = (angle - arc.startAngle) * arc.direction - while (directedAngle < 0) directedAngle += Math.PI * 2 - const t = Math.max(0, Math.min(1, directedAngle / Math.abs(arc.delta))) - const frame = getWallCurveFrameAt(wall, t) - const signedOffset = - (point.x - frame.point.x) * frame.normal.x + (point.y - frame.point.y) * frame.normal.y + if (chord.length <= 1e-6) return null + const point: [number, number] = [ + chord.start.x + chord.tangent.x * localPosition[0] + chord.normal.x * localPosition[2], + chord.start.y + chord.tangent.y * localPosition[0] + chord.normal.y * localPosition[2], + ] + const attachment = resolveWallAttachmentAtPlanPoint(wall, point) + if (!attachment) return null return { - localX: getWallCurveLength(wall) * t, - side: signedOffset >= 0 ? 'front' : 'back', + localX: attachment.localX, + side: attachment.side, } } @@ -138,18 +135,28 @@ export function resolveLeanToLayout(node: LeanToExtensionNode): LeanToLayout { 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 closedLoop = isClosedLoopLeanTo(node) const postCount = node.postLayoutMode === 'target-spacing' - ? Math.max(2, Math.min(20, Math.ceil(usablePostSpan / node.postSpacing) + 1)) + ? Math.max( + closedLoop ? 3 : 2, + Math.min(20, Math.ceil(usablePostSpan / node.postSpacing) + (closedLoop ? 0 : 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 postXs = closedLoop + ? evenlySpacedLoopXs(span, postCount) + : evenlySpacedXs(span, postCount, node.postInset) + const beamSpan = closedLoop + ? span + : Math.max(node.postWidth, (postXs.at(-1) ?? 0) - (postXs[0] ?? 0) + node.postWidth) const usableRafterSpan = Math.max(0.1, span - 2 * Math.max(0, node.rafterEndInset)) - const rafterCount = Math.max(2, Math.ceil(usableRafterSpan / node.rafterSpacing) + 1) - const rafterXs = evenlySpacedXs(span, rafterCount, node.rafterEndInset) + const rafterCount = Math.max( + closedLoop ? 3 : 2, + Math.ceil(usableRafterSpan / node.rafterSpacing) + (closedLoop ? 0 : 1), + ) + const rafterXs = closedLoop + ? evenlySpacedLoopXs(span, rafterCount) + : evenlySpacedXs(span, rafterCount, node.rafterEndInset) return { span, @@ -179,6 +186,16 @@ export function resolveLeanToLayout(node: LeanToExtensionNode): LeanToLayout { } } +/** + * Plan-space center of the rendered lean-to footprint, measured from the node + * origin. Placement tools use this shared offset so the pointer marks the + * center of the whole footprint rather than the high-edge origin. + */ +export function resolveLeanToPlanCenter(node: LeanToExtensionNode): [number, number] { + const layout = resolveLeanToLayout(node) + return [layout.roofCenterX, layout.roofCenterZ] +} + // The host wall's true circular arc expressed in the lean-to's local frame. The // anchor frame is sampled at the lean-to's along-wall position (the span center), // so the arc center lies on the local Z axis (local X = 0): `centerZ` is its local @@ -210,24 +227,161 @@ export function resolveLeanToMoveCenterX( snapStep = 0, edgeSnapTargets: readonly LeanToEdgeSnapTarget[] = [], ): number { + return resolveLeanToMoveProposal({ + node, + wall, + rawLocalX, + rawHighEdgeHeight: node.highEdgeHeight, + snapStep, + edgeSnapTargets, + }).centerX +} + +export type LeanToMoveProposal = { + centerX: number + highEdgeHeight: number + lowEdgeHeight: number +} + +export function resolveLeanToMoveProposal({ + node, + wall, + rawLocalX, + rawHighEdgeHeight, + snapStep = 0, + edgeSnapTargets = [], +}: { + node: LeanToExtensionNode + wall: WallNode + rawLocalX: number + rawHighEdgeHeight: number + snapStep?: number + edgeSnapTargets?: readonly LeanToEdgeSnapTarget[] +}): LeanToMoveProposal { const wallLength = getWallCurveLength(wall) const snapped = snapStep > 0 ? Math.round(rawLocalX / snapStep) * snapStep : rawLocalX const min = node.span / 2 + Math.max(0, node.leftOverhang) const max = wallLength - node.span / 2 - Math.max(0, node.rightOverhang) - if (max < min) return wallLength / 2 + const rawHeightDelta = rawHighEdgeHeight - node.highEdgeHeight + if (max < min) { + return { + centerX: wallLength / 2, + highEdgeHeight: rawHighEdgeHeight, + lowEdgeHeight: node.lowEdgeHeight + rawHeightDelta, + } + } const clamped = Math.max(min, Math.min(max, snapped)) - return snapLeanToMoveCenterToEdges(node, clamped, min, max, edgeSnapTargets) + const edgeSnap = snapLeanToMoveCenterToEdges(node, clamped, min, max, edgeSnapTargets) + const highEdgeHeight = edgeSnap ? edgeSnap.target.roofEdgeY - node.position[1] : rawHighEdgeHeight + return { + centerX: edgeSnap?.centerX ?? clamped, + highEdgeHeight, + lowEdgeHeight: node.lowEdgeHeight + highEdgeHeight - node.highEdgeHeight, + } } export type LeanToEdgeSnapTarget = { leftEdgeX: number rightEdgeX: number + roofEdgeY: number + pitch?: number + nodeId?: AnyNodeId + anchor?: readonly [number, number] +} + +export type LeanToHeightSnapMatch = { + highEdgeHeight: number + target: LeanToEdgeSnapTarget } function leanToEdgeSnapTarget(node: LeanToExtensionNode): LeanToEdgeSnapTarget { return { leftEdgeX: node.position[0] - node.span / 2 - Math.max(0, node.leftOverhang), rightEdgeX: node.position[0] + node.span / 2 + Math.max(0, node.rightOverhang), + roofEdgeY: node.position[1] + node.highEdgeHeight, + pitch: node.pitch, + } +} + +export type LeanToSpanResizeSide = 'left' | 'right' + +export type LeanToSpanResizeProposal = { + span: number + position: [number, number, number] + highEdgeHeight: number + lowEdgeHeight: number + pitch: number + target: LeanToEdgeSnapTarget | null +} + +export function resolveLeanToSpanResizeProposal({ + node, + wall, + rawSpan, + side, + edgeSnapTargets = [], + tolerance = LEAN_TO_EDGE_SNAP_TOLERANCE, +}: { + node: LeanToExtensionNode + wall: WallNode + rawSpan: number + side: LeanToSpanResizeSide + edgeSnapTargets?: readonly LeanToEdgeSnapTarget[] + tolerance?: number +}): LeanToSpanResizeProposal { + const wallLength = getWallCurveLength(wall) + const visualSign = side === 'right' ? 1 : -1 + const wallSign = Math.cos(node.rotation[1]) >= 0 ? visualSign : -visualSign + const fixedStructuralEdge = node.position[0] - wallSign * (node.span / 2) + const draggedOverhang = Math.max(0, wallSign > 0 ? node.rightOverhang : node.leftOverhang) + const maximumSpan = Math.max( + 0.5, + wallSign > 0 + ? wallLength - fixedStructuralEdge - draggedOverhang + : fixedStructuralEdge - draggedOverhang, + ) + const boundedSpan = Math.max(0.5, Math.min(maximumSpan, rawSpan)) + const centerX = fixedStructuralEdge + wallSign * (boundedSpan / 2) + const draggedRoofEdge = centerX + wallSign * (boundedSpan / 2 + draggedOverhang) + const wallEdgeX = wallSign > 0 ? wallLength : 0 + let best: { edgeX: number; distance: number; target: LeanToEdgeSnapTarget | null } = { + edgeX: wallEdgeX, + distance: Math.abs(draggedRoofEdge - wallEdgeX), + target: null, + } + + for (const target of edgeSnapTargets) { + const edgeX = wallSign > 0 ? target.leftEdgeX : target.rightEdgeX + const distance = Math.abs(draggedRoofEdge - edgeX) + if (distance < best.distance || (Math.abs(distance - best.distance) <= 1e-9 && !best.target)) { + best = { edgeX, distance, target } + } + } + + const snapped = best.distance <= tolerance + const span = snapped + ? Math.max(0.5, Math.min(maximumSpan, boundedSpan + wallSign * (best.edgeX - draggedRoofEdge))) + : boundedSpan + const position: [number, number, number] = [ + fixedStructuralEdge + wallSign * (span / 2), + node.position[1], + node.position[2], + ] + const target = snapped ? best.target : null + const pitch = target?.pitch ?? node.pitch + const highEdgeHeight = target ? target.roofEdgeY - node.position[1] : node.highEdgeHeight + + return { + span, + position, + highEdgeHeight, + lowEdgeHeight: leanToLowEdgeHeight({ + highEdgeHeight, + pitch, + projection: node.projection, + }), + pitch, + target, } } @@ -237,10 +391,10 @@ function snapLeanToMoveCenterToEdges( min: number, max: number, targets: readonly LeanToEdgeSnapTarget[], -): number { +): { centerX: number; target: LeanToEdgeSnapTarget } | null { const movingLeft = centerX - node.span / 2 - Math.max(0, node.leftOverhang) const movingRight = centerX + node.span / 2 + Math.max(0, node.rightOverhang) - let best: { centerX: number; distance: number } | null = null + let best: { centerX: number; distance: number; target: LeanToEdgeSnapTarget } | null = null for (const target of targets) { const leftToRight = Math.abs(movingLeft - target.rightEdgeX) @@ -249,7 +403,7 @@ function snapLeanToMoveCenterToEdges( if (snappedCenter >= min && snappedCenter <= max) { best = !best || leftToRight < best.distance - ? { centerX: snappedCenter, distance: leftToRight } + ? { centerX: snappedCenter, distance: leftToRight, target } : best } } @@ -260,13 +414,51 @@ function snapLeanToMoveCenterToEdges( if (snappedCenter >= min && snappedCenter <= max) { best = !best || rightToLeft < best.distance - ? { centerX: snappedCenter, distance: rightToLeft } + ? { centerX: snappedCenter, distance: rightToLeft, target } : best } } } - return best?.centerX ?? centerX + return best ? { centerX: best.centerX, target: best.target } : null +} + +export function resolveLeanToHighEdgeHeightSnap( + node: LeanToExtensionNode, + rawHighEdgeHeight: number, + targets: readonly LeanToEdgeSnapTarget[], + tolerance = LEAN_TO_HEIGHT_SNAP_TOLERANCE, +): LeanToHeightSnapMatch | null { + const movingLeft = node.position[0] - node.span / 2 - Math.max(0, node.leftOverhang) + const movingRight = node.position[0] + node.span / 2 + Math.max(0, node.rightOverhang) + let best: { heightDelta: number; edgeDistance: number; target: LeanToEdgeSnapTarget } | null = + null + + for (const target of targets) { + const edgeDistance = Math.min( + Math.abs(movingLeft - target.rightEdgeX), + Math.abs(movingRight - target.leftEdgeX), + ) + if (edgeDistance > LEAN_TO_EDGE_SNAP_TOLERANCE) continue + + const targetHeight = target.roofEdgeY - node.position[1] + const heightDelta = Math.abs(targetHeight - rawHighEdgeHeight) + if (heightDelta > tolerance) continue + if ( + !best || + heightDelta < best.heightDelta - 1e-9 || + (Math.abs(heightDelta - best.heightDelta) <= 1e-9 && edgeDistance < best.edgeDistance) + ) { + best = { heightDelta, edgeDistance, target } + } + } + + return best + ? { + highEdgeHeight: best.target.roofEdgeY - node.position[1], + target: best.target, + } + : null } export function resolveLeanToEdgeSnapTargets( @@ -274,24 +466,73 @@ export function resolveLeanToEdgeSnapTargets( wall: WallNode, nodes: Record, ): LeanToEdgeSnapTarget[] { - const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + const wallLength = getWallCurveLength(wall) if (wallLength <= 1e-6) return [] - const wallDx = (wall.end[0] - wall.start[0]) / wallLength - const wallDz = (wall.end[1] - wall.start[1]) / wallLength + const wallChordLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + if (wallChordLength <= 1e-6) return [] + const wallDx = (wall.end[0] - wall.start[0]) / wallChordLength + const wallDz = (wall.end[1] - wall.start[1]) / wallChordLength const sameSideSign = Math.sign(Math.cos(node.rotation[1])) || 1 const targets: LeanToEdgeSnapTarget[] = [] for (const candidate of Object.values(nodes)) { if (candidate.type !== 'lean-to-extension' || candidate.id === node.id) continue - if ((Math.sign(Math.cos(candidate.rotation[1])) || 1) !== sameSideSign) continue const host = candidate.parentId ? nodes[candidate.parentId as AnyNodeId] : undefined if (host?.type !== 'wall') continue - const hostLength = Math.hypot(host.end[0] - host.start[0], host.end[1] - host.start[1]) + if (host.parentId !== wall.parentId) continue + const hostLength = getWallCurveLength(host) if (hostLength <= 1e-6) continue - const hostDx = (host.end[0] - host.start[0]) / hostLength - const hostDz = (host.end[1] - host.start[1]) / hostLength + const hostChordLength = Math.hypot(host.end[0] - host.start[0], host.end[1] - host.start[1]) + if (hostChordLength <= 1e-6) continue + const hostDx = (host.end[0] - host.start[0]) / hostChordLength + const hostDz = (host.end[1] - host.start[1]) / hostChordLength const parallel = wallDx * hostDx + wallDz * hostDz - if (parallel < 0.999) continue + const candidateTarget = leanToEdgeSnapTarget(candidate) + const candidatePose = leanToWallLocalPose(host, candidate, 0) + + if (parallel < 0.999) { + const wallEnds = [ + { point: wall.start, x: 0, t: 0 }, + { point: wall.end, x: wallLength, t: 1 }, + ] as const + const hostEnds = [ + { point: host.start, x: 0, t: 0 }, + { point: host.end, x: hostLength, t: 1 }, + ] as const + for (const wallEnd of wallEnds) { + for (const hostEnd of hostEnds) { + if ( + Math.hypot(wallEnd.point[0] - hostEnd.point[0], wallEnd.point[1] - hostEnd.point[1]) > + LEAN_TO_EDGE_SNAP_TOLERANCE + ) { + continue + } + const candidateReachesEnd = + Math.min( + Math.abs(candidateTarget.leftEdgeX - hostEnd.x), + Math.abs(candidateTarget.rightEdgeX - hostEnd.x), + ) <= LEAN_TO_EDGE_SNAP_TOLERANCE + if (!candidateReachesEnd) continue + const wallFrame = getWallCurveFrameAt(wall, wallEnd.t) + const hostFrame = getWallCurveFrameAt(host, hostEnd.t) + const candidateSideSign = Math.sign(Math.cos(candidate.rotation[1])) || 1 + const outwardDot = + wallFrame.normal.x * sameSideSign * hostFrame.normal.x * candidateSideSign + + wallFrame.normal.y * sameSideSign * hostFrame.normal.y * candidateSideSign + if (outwardDot < -0.25) continue + targets.push({ + leftEdgeX: wallEnd.x, + rightEdgeX: wallEnd.x, + roofEdgeY: candidateTarget.roofEdgeY, + pitch: candidate.pitch, + nodeId: candidate.id as AnyNodeId, + anchor: [candidatePose.position[0], candidatePose.position[2]], + }) + } + } + continue + } + if ((Math.sign(Math.cos(candidate.rotation[1])) || 1) !== sameSideSign) continue const offsetFromWall = (host.start[0] - wall.start[0]) * -wallDz + (host.start[1] - wall.start[1]) * wallDx if (Math.abs(offsetFromWall) > (wall.thickness ?? 0.1) + LEAN_TO_EDGE_SNAP_TOLERANCE) { @@ -299,10 +540,13 @@ export function resolveLeanToEdgeSnapTargets( } const hostStartX = (host.start[0] - wall.start[0]) * wallDx + (host.start[1] - wall.start[1]) * wallDz - const candidateTarget = leanToEdgeSnapTarget(candidate) targets.push({ leftEdgeX: hostStartX + candidateTarget.leftEdgeX, rightEdgeX: hostStartX + candidateTarget.rightEdgeX, + roofEdgeY: candidateTarget.roofEdgeY, + pitch: candidate.pitch, + nodeId: candidate.id as AnyNodeId, + anchor: [candidatePose.position[0], candidatePose.position[2]], }) } @@ -318,6 +562,12 @@ function evenlySpacedXs(span: number, count: number, requestedInset: number): nu return Array.from({ length: resolvedCount }, (_, index) => first + index * step) } +function evenlySpacedLoopXs(span: number, count: number): number[] { + const resolvedCount = Math.max(3, Math.round(count)) + const step = span / resolvedCount + return Array.from({ length: resolvedCount }, (_, index) => -span / 2 + index * step) +} + export function resolveLeanToWallPlacement( wall: WallNode, rawLocalX: number, diff --git a/packages/nodes/src/lean-to-extension/linear-joint.test.ts b/packages/nodes/src/lean-to-extension/linear-joint.test.ts new file mode 100644 index 0000000000..6b768ad504 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/linear-joint.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + LeanToExtensionNode, + type LeanToExtensionNode as LeanToExtensionNodeType, + WallNode, +} from '@pascal-app/core' +import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' +import { resolveLeanToCornerJoints } from './corner-joint' +import { resolveLeanToLayout } from './layout' + +function linearFixture(overrides: Partial = {}) { + const wall = WallNode.parse({ + id: 'wall_linear_joint', + parentId: 'level_linear_joint', + start: [0, 0], + end: [12, 0], + }) + const left = LeanToExtensionNode.parse({ + id: 'leanto_linear_left', + parentId: wall.id, + position: [2, 0, 0.05], + span: 4, + leftOverhang: 0.15, + rightOverhang: 0.15, + }) + const right = LeanToExtensionNode.parse({ + id: 'leanto_linear_right', + parentId: wall.id, + position: [6.3, 0, 0.05], + span: 4, + leftOverhang: 0.15, + rightOverhang: 0.15, + ...overrides, + }) + const nodes = { + [wall.id]: { ...wall, children: [left.id, right.id] }, + [left.id]: left, + [right.id]: right, + } as Record + return { wall, left, right, nodes } +} + +describe('lean-to linear joints', () => { + test('turns two edge-snapped extensions into one reciprocal structural joint', () => { + const { wall, left, right, nodes } = linearFixture() + const leftJoint = resolveLeanToCornerJoints(left, wall, nodes).right + const rightJoint = resolveLeanToCornerJoints(right, wall, nodes).left + + expect(leftJoint).toMatchObject({ + kind: 'linear', + neighborId: right.id, + neighborSide: 'left', + gutterMitre: 0, + }) + expect(rightJoint).toMatchObject({ + kind: 'linear', + neighborId: left.id, + neighborSide: 'right', + gutterMitre: 0, + }) + expect(Number(leftJoint?.sharedPostOwner) + Number(rightJoint?.sharedPostOwner)).toBe(1) + expect(left.position[0] + (leftJoint?.sharedPostPosition[0] ?? 0)).toBeCloseTo( + right.position[0] + (rightJoint?.sharedPostPosition[0] ?? 0), + 6, + ) + }) + + test('opens the internal roof and gutter ends and generates one joint pillar', () => { + const { left, right, nodes } = linearFixture() + const leftAssembly = createLeanToAssembly(left, undefined, nodes) + const rightAssembly = createLeanToAssembly(right, undefined, nodes) + + expect(leftAssembly.segment.shedOpenEndSides).toContain('right') + expect(rightAssembly.segment.shedOpenEndSides).toContain('left') + expect(leftAssembly.gutter.endCapRight).toBe(false) + expect(rightAssembly.gutter.endCapLeft).toBe(false) + expect(leftAssembly.gutter.endCapLeft).toBe(true) + expect(rightAssembly.gutter.endCapRight).toBe(true) + + const posts = [...leftAssembly.posts, ...rightAssembly.posts] + const sharedPosts = posts.filter((post) => { + const index = managedLeanToPostIndex(post) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }) + const ordinaryCount = + resolveLeanToLayout(left).postXs.length + resolveLeanToLayout(right).postXs.length + expect(sharedPosts).toHaveLength(1) + expect(posts).toHaveLength(ordinaryCount - 1) + }) + + test('does not connect roofs whose edge profiles do not meet', () => { + const heightMismatch = linearFixture({ highEdgeHeight: 3.2 }) + expect( + resolveLeanToCornerJoints(heightMismatch.left, heightMismatch.wall, heightMismatch.nodes) + .right, + ).toBeUndefined() + + const separated = linearFixture({ position: [6.6, 0, 0.05] }) + expect( + resolveLeanToCornerJoints(separated.left, separated.wall, separated.nodes).right, + ).toBeUndefined() + }) + + test('keeps straight snap connectivity independent from corner-miter preference', () => { + const { wall, left, right, nodes } = linearFixture({ autoMiterCorners: false }) + const leftWithoutCornerMitres = { ...left, autoMiterCorners: false } + const resolvedNodes = { + ...nodes, + [left.id]: leftWithoutCornerMitres, + [right.id]: right, + } + + expect( + resolveLeanToCornerJoints(leftWithoutCornerMitres, wall, resolvedNodes).right?.kind, + ).toBe('linear') + }) +}) diff --git a/packages/nodes/src/lean-to-extension/managed-preview.ts b/packages/nodes/src/lean-to-extension/managed-preview.ts new file mode 100644 index 0000000000..ff96610c32 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/managed-preview.ts @@ -0,0 +1,83 @@ +import type { AnyNode, AnyNodeId, LeanToExtensionNode, SceneApi } from '@pascal-app/core' +import { + isManagedLeanToNode, + isManagedLeanToPost, + leanToDownspoutLayoutPatch, + leanToGutterLayoutPatch, + leanToPostLayoutPatch, + leanToRoofSegmentLayoutPatch, + managedLeanToPostIndex, + managedLeanToPostSide, + resolveLeanToPostBaseY, + resolveLeanToPostGutterSetback, +} from './assembly' + +export function leanToManagedPreviewOverrides( + node: LeanToExtensionNode, + patch: Partial, + sceneApi: SceneApi, +): ReadonlyArray]> { + const next = { ...node, ...patch } as LeanToExtensionNode + const nodes = sceneApi.nodes() as Record + const entries: Array]> = [] + + const wall = next.parentId ? nodes[next.parentId as AnyNodeId] : undefined + for (const childId of next.children) { + const child = nodes[childId as AnyNodeId] + if (!child) continue + + if (child.type === 'column' && isManagedLeanToPost(child, next.id)) { + const index = managedLeanToPostIndex(child) + if (index === null) continue + const side = managedLeanToPostSide(child) + const baseY = + wall?.type === 'wall' ? resolveLeanToPostBaseY(next, wall, nodes, index, side) : 0 + const gutterSetback = side === 'low' ? resolveLeanToPostGutterSetback(next, child) : 0 + entries.push([ + child.id as AnyNodeId, + leanToPostLayoutPatch(next, index, baseY, gutterSetback, side) as Partial, + ]) + continue + } + + if (child.type !== 'roof' || !isManagedLeanToNode(child, next.id, 'roof')) continue + const segment = child.children + .map((id) => nodes[id as AnyNodeId]) + .find( + (candidate) => + candidate?.type === 'roof-segment' && + isManagedLeanToNode(candidate, next.id, 'roof-segment'), + ) + if (segment?.type !== 'roof-segment') continue + + const segmentPatch = leanToRoofSegmentLayoutPatch(next, nodes) + entries.push([segment.id as AnyNodeId, segmentPatch as Partial]) + + const nextSegment = { ...segment, ...segmentPatch } + const gutter = segment.children + .map((id) => nodes[id as AnyNodeId]) + .find( + (candidate) => + candidate?.type === 'gutter' && isManagedLeanToNode(candidate, next.id, 'gutter'), + ) + if (gutter?.type !== 'gutter') continue + const gutterPatch = leanToGutterLayoutPatch(nextSegment, next, gutter, nodes) + entries.push([gutter.id as AnyNodeId, gutterPatch as Partial]) + + const nextGutter = { ...gutter, ...gutterPatch } + const downspout = segment.children + .map((id) => nodes[id as AnyNodeId]) + .find( + (candidate) => + candidate?.type === 'downspout' && isManagedLeanToNode(candidate, next.id, 'downspout'), + ) + if (downspout?.type === 'downspout') { + entries.push([ + downspout.id as AnyNodeId, + leanToDownspoutLayoutPatch(nextSegment, nextGutter, next, downspout) as Partial, + ]) + } + } + + return entries +} diff --git a/packages/nodes/src/lean-to-extension/move-tool.tsx b/packages/nodes/src/lean-to-extension/move-tool.tsx index 7bc9495462..cb5b03e48a 100644 --- a/packages/nodes/src/lean-to-extension/move-tool.tsx +++ b/packages/nodes/src/lean-to-extension/move-tool.tsx @@ -4,91 +4,258 @@ import { type AnyNode, type AnyNodeId, emitter, + type GridEvent, + getLevelElevations, + getWallBaseElevationForNodes, type LeanToExtensionNode, type SceneApi, + sceneRegistry, useLiveNodeOverrides, type WallEvent, type WallNode, } from '@pascal-app/core' import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' -import { useEffect } from 'react' -import { resolveLeanToEdgeSnapTargets, resolveLeanToMoveCenterX } from './layout' +import { useLayoutEffect, useState } from 'react' +import { leanToExtensionGeometryKey } from './geometry' +import { + leanToWallLocalPose, + resolveLeanToEdgeSnapTargets, + resolveLeanToMoveProposal, +} from './layout' +import { moveLeanToAlongSlabEdge, resolveLeanToPlanPosition } from './placement' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' +import LeanToExtensionPreview from './preview' type MoveLeanToExtensionProps = { node: LeanToExtensionNode sceneApi: SceneApi } +type MovePreview = { + node: LeanToExtensionNode + position: [number, number, number] + rotationY: number + valid: boolean +} + const MoveLeanToExtensionTool = ({ node, sceneApi }: MoveLeanToExtensionProps) => { - useEffect(() => { + const [preview, setPreview] = useState(null) + + useLayoutEffect(() => { const parent = node.parentId ? sceneApi.get(node.parentId as AnyNodeId) : undefined - if (parent?.type !== 'wall') return - const wall = parent as WallNode + const wall = parent?.type === 'wall' ? (parent as WallNode) : null + const levelHosted = + parent?.type === 'level' && + (node.hostKind === 'freestanding' || node.hostKind === 'slab-edge') + if (!(wall || levelHosted)) return + let lastPatch: Partial | null = null + let dragStartLocalY: number | null = null + const movedObject = sceneRegistry.nodes.get(node.id) + const movedObjectVisible = movedObject?.visible + if (movedObject) movedObject.visible = false + const restoreRaycasts: Array<() => void> = [] + movedObject?.traverse((child) => { + const original = child.raycast + child.raycast = () => {} + restoreRaycasts.push(() => { + child.raycast = original + }) + }) - 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 liveOverrides = useLiveNodeOverrides.getState() + const previousVisibleOverride = liveOverrides.get(node.id)?.visible + liveOverrides.set(node.id, { visible: false }) + + const resolveBaseY = () => { + if (!wall) return 0 const nodes = sceneApi.nodes() as Record - const position: LeanToExtensionNode['position'] = [ - resolveLeanToMoveCenterX( - node, - wall, - rawLocalX, - gridStep, - event.nativeEvent.altKey ? [] : resolveLeanToEdgeSnapTargets(node, wall, nodes), - ), - node.position[1], - node.position[2], - ] - const candidate = resolveLeanToEndAbutments( - { ...node, position, autoSpan: false }, - wall, - nodes, - ) - const patch: Partial = { - position, - autoSpan: false, - leftEndCondition: candidate.leftEndCondition, - rightEndCondition: candidate.rightEndCondition, - downspoutPosition: candidate.downspoutPosition, - } - useLiveNodeOverrides.getState().set(node.id as AnyNodeId, patch) - sceneApi.markDirty(node.id as AnyNodeId) - lastPatch = leanToPlacementConflicts(candidate, wall, nodes).length === 0 ? patch : null - return lastPatch + const levelY = wall.parentId ? (getLevelElevations(nodes).get(wall.parentId)?.baseY ?? 0) : 0 + return levelY + getWallBaseElevationForNodes(wall, nodes) + } + + const publishPatch = (patch: Partial, valid = true) => { + const candidate = { ...node, ...patch } as LeanToExtensionNode + const pose = wall + ? leanToWallLocalPose(wall, candidate, resolveBaseY()) + : { position: candidate.position, rotationY: candidate.rotation[1] } + setPreview((current) => ({ + node: + current && + leanToExtensionGeometryKey(current.node) === leanToExtensionGeometryKey(candidate) + ? current.node + : candidate, + ...pose, + valid, + })) + lastPatch = valid ? patch : null + return valid ? patch : null } - const onMove = (event: WallEvent) => { - resolvePatch(event) + publishPatch({}) + + const restoreSource = () => { + if (movedObject && movedObjectVisible !== undefined) movedObject.visible = movedObjectVisible + const overrides = useLiveNodeOverrides.getState() + if (previousVisibleOverride === undefined) { + overrides.clearFields(node.id, ['visible']) + } else { + overrides.set(node.id, { visible: previousVisibleOverride }) + } } - 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) + + const commit = () => { + if (!lastPatch) return + sceneApi.update(node.id as AnyNodeId, lastPatch 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) + const cleanUp = () => { + restoreSource() + for (const restore of restoreRaycasts) restore() lastPatch = null } + + if (wall) { + const resolvePatch = (event: WallEvent) => { + if (event.node.id !== wall.id) return null + dragStartLocalY ??= event.localPosition[1] + const rawHighEdgeHeight = Math.max( + 0.8, + Math.min(10, node.highEdgeHeight + event.localPosition[1] - dragStartLocalY), + ) + const gridStep = + !event.nativeEvent.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const nodes = sceneApi.nodes() as Record + const proposal = resolveLeanToMoveProposal({ + node, + wall, + rawLocalX: event.localPosition[0], + rawHighEdgeHeight, + snapStep: gridStep, + edgeSnapTargets: event.nativeEvent.altKey + ? [] + : resolveLeanToEdgeSnapTargets(node, wall, nodes), + }) + const position: LeanToExtensionNode['position'] = [ + proposal.centerX, + node.position[1], + node.position[2], + ] + const connectionOffset = + node.connectionMode === 'auto' + ? Math.max( + -1, + Math.min(1, node.connectionOffset + proposal.highEdgeHeight - node.highEdgeHeight), + ) + : node.connectionOffset + const candidate = resolveLeanToEndAbutments( + { + ...node, + position, + highEdgeHeight: proposal.highEdgeHeight, + lowEdgeHeight: proposal.lowEdgeHeight, + connectionOffset, + autoSpan: false, + }, + wall, + nodes, + ) + const patch: Partial = { + position, + highEdgeHeight: proposal.highEdgeHeight, + lowEdgeHeight: proposal.lowEdgeHeight, + connectionOffset, + autoSpan: false, + leftEndCondition: candidate.leftEndCondition, + rightEndCondition: candidate.rightEndCondition, + downspoutPosition: candidate.downspoutPosition, + } + if ( + !event.nativeEvent.altKey && + leanToPlacementConflicts(candidate, wall, nodes).length > 0 + ) { + publishPatch(patch, false) + return null + } + return publishPatch(patch) + } + const onMove = (event: WallEvent) => { + resolvePatch(event) + } + const onClick = (event: WallEvent) => { + if (!resolvePatch(event)) return + event.stopPropagation() + commit() + } + 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) + cleanUp() + } + } + + if ( + parent?.type === 'level' && + (node.hostKind === 'freestanding' || node.hostKind === 'slab-edge') + ) { + const resolvePatch = (event: GridEvent): Partial | null => { + if (node.hostKind === 'slab-edge') { + const resolved = moveLeanToAlongSlabEdge( + node, + [event.localPosition[0], event.localPosition[2]], + sceneApi.nodes(), + ) + if (!resolved) return null + return publishPatch({ + hostSlabEdgeT: resolved.hostSlabEdgeT, + position: resolved.position, + rotation: resolved.rotation, + span: resolved.span, + highEdgeHeight: resolved.highEdgeHeight, + lowEdgeHeight: resolved.lowEdgeHeight, + }) + } + const step = + !event.nativeEvent.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) + return publishPatch({ + position: resolveLeanToPlanPosition(node, [ + snap(event.localPosition[0]), + snap(event.localPosition[2]), + ]), + }) + } + const onMove = (event: GridEvent) => { + resolvePatch(event) + } + const onClick = (event: GridEvent) => { + if (!resolvePatch(event)) return + commit() + } + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + cleanUp() + } + } + + return cleanUp }, [node, sceneApi]) - return null + if (!preview) return null + return ( + + + + ) } export default MoveLeanToExtensionTool diff --git a/packages/nodes/src/lean-to-extension/parametrics.ts b/packages/nodes/src/lean-to-extension/parametrics.ts index b8061445af..c18e7fb86a 100644 --- a/packages/nodes/src/lean-to-extension/parametrics.ts +++ b/packages/nodes/src/lean-to-extension/parametrics.ts @@ -75,6 +75,12 @@ export const leanToExtensionParametrics: ParametricDescriptor node.hostKind !== 'freestanding', + }, { key: 'span', label: 'Width', @@ -103,7 +114,7 @@ export const leanToExtensionParametrics: ParametricDescriptor node.hostKind === 'wall', }, { key: 'highSideMode', - label: 'Wall side', + label: 'High-side support', kind: 'enum', options: ['wall-ledger', 'independent-high-beam'], + visibleIf: (node) => node.hostKind === 'wall', }, { key: 'connectionOffset', @@ -310,7 +323,7 @@ export const leanToExtensionParametrics: ParametricDescriptor { + test('accepts hosts only when their level ancestor is active', () => { + const ground = LevelNode.parse({ id: 'level_ground', level: 0 }) + const upper = LevelNode.parse({ id: 'level_upper', level: 1 }) + const groundWall = WallNode.parse({ + id: 'wall_ground', + parentId: ground.id, + start: [0, 0], + end: [4, 0], + }) + const upperWall = WallNode.parse({ + id: 'wall_upper', + parentId: upper.id, + start: [0, 0], + end: [4, 0], + }) + const upperRoof = RoofNode.parse({ id: 'roof_upper', parentId: upper.id }) + const upperSegment = RoofSegmentNode.parse({ + id: 'rseg_upper', + parentId: upperRoof.id, + roofType: 'conical', + }) + const nodes = Object.fromEntries( + [ground, upper, groundWall, upperWall, upperRoof, upperSegment].map((node) => [ + node.id, + node, + ]), + ) as Record + + expect(isLeanToHostOnLevel(groundWall, nodes, ground.id)).toBe(true) + expect(isLeanToHostOnLevel(upperWall, nodes, ground.id)).toBe(false) + expect(isLeanToHostOnLevel(upperSegment, nodes, ground.id)).toBe(false) + expect(isLeanToHostOnLevel(upperSegment, nodes, upper.id)).toBe(true) + }) + + test('rejects an orphaned host', () => { + const wall = WallNode.parse({ id: 'wall_orphan', start: [0, 0], end: [4, 0] }) + + expect(isLeanToHostOnLevel(wall, { [wall.id]: wall }, 'level_active')).toBe(false) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/placement-scope.ts b/packages/nodes/src/lean-to-extension/placement-scope.ts new file mode 100644 index 0000000000..f748827a39 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement-scope.ts @@ -0,0 +1,9 @@ +import { type AnyNode, type AnyNodeId, findLevelAncestorId } from '@pascal-app/core' + +export function isLeanToHostOnLevel( + host: AnyNode, + nodes: Record, + activeLevelId: AnyNodeId, +): boolean { + return findLevelAncestorId(host.id as AnyNodeId, nodes) === activeLevelId +} diff --git a/packages/nodes/src/lean-to-extension/placement-validation.test.ts b/packages/nodes/src/lean-to-extension/placement-validation.test.ts index 8cb5d700e9..c0ccb353ba 100644 --- a/packages/nodes/src/lean-to-extension/placement-validation.test.ts +++ b/packages/nodes/src/lean-to-extension/placement-validation.test.ts @@ -10,6 +10,7 @@ import { WallNode, WindowNode, } from '@pascal-app/core' +import { resolveLeanToCornerJoints } from './corner-joint' import { resolveLeanToWallPlacement } from './layout' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' import { applyLeanToWallAutoSpan } from './roof-attachment' @@ -129,6 +130,50 @@ describe('lean-to placement validation', () => { expect(leanToPlacementConflicts(candidate, straightWall, nodes)).toEqual([]) }) + test('allows a convex curved-to-straight corner with an overlapping footprint', () => { + const curvedWall = WallNode.parse({ + id: 'wall_curved_convex_placement', + parentId: 'level_convex_placement', + start: [0, 0], + end: [6, 0], + curveOffset: -0.5, + children: ['leanto_curved_convex_placement'], + }) + const straightWall = WallNode.parse({ + id: 'wall_straight_convex_placement', + parentId: 'level_convex_placement', + start: [6, 0], + end: [6, -6], + }) + const curvedPlacement = resolveLeanToWallPlacement( + curvedWall, + getWallCurveLength(curvedWall) / 2, + 'front', + )! + const existing = { + ...applyLeanToWallAutoSpan(curvedPlacement, curvedWall), + id: 'leanto_curved_convex_placement', + rightOverhang: 4, + } + const straightPlacement = resolveLeanToWallPlacement(straightWall, 3, 'front')! + const candidate = { + ...applyLeanToWallAutoSpan(straightPlacement, straightWall), + leftOverhang: 4, + } + const nodes = { + [curvedWall.id]: curvedWall, + [straightWall.id]: straightWall, + [existing.id]: existing, + } as Record + + expect( + Object.values(resolveLeanToCornerJoints(candidate, straightWall, nodes)).some( + (joint) => joint?.kind === 'convex' && joint.neighborId === existing.id, + ), + ).toBe(true) + expect(leanToPlacementConflicts(candidate, straightWall, nodes)).toEqual([]) + }) + test('rejects an adjacent building crossing the canopy footprint', () => { const building = BuildingNode.parse({ id: 'building_host' }) const level = LevelNode.parse({ id: 'level_host', parentId: building.id }) diff --git a/packages/nodes/src/lean-to-extension/placement-validation.ts b/packages/nodes/src/lean-to-extension/placement-validation.ts index 7ace9bd17b..2175443b8a 100644 --- a/packages/nodes/src/lean-to-extension/placement-validation.ts +++ b/packages/nodes/src/lean-to-extension/placement-validation.ts @@ -356,14 +356,14 @@ export function leanToPlacementConflicts( if (node.type !== 'lean-to-extension' || node.id === leanTo.id || node.parentId === wall.id) continue const host = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined - const supportedConcaveJoint = + const supportedCornerJoint = host?.type === 'wall' && Object.values(resolveLeanToCornerJoints(leanTo, wall, nodes)).some( - (joint) => joint?.kind === 'concave' && joint.neighborId === node.id, + (joint) => joint?.neighborId === node.id, ) if ( host?.type === 'wall' && - !supportedConcaveJoint && + !supportedCornerJoint && boundsOverlap( candidateWorldBounds, transformBounds(planBounds(node, host), ancestorBuilding(host, nodes)), diff --git a/packages/nodes/src/lean-to-extension/placement.test.ts b/packages/nodes/src/lean-to-extension/placement.test.ts new file mode 100644 index 0000000000..7a4471893e --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + BuildingNode, + getWallCurveLength, + LevelNode, + SlabNode, + WallNode, +} from '@pascal-app/core' +import { readLeanToCornerJointMetadata } from './corner-joint' +import { resolveLeanToLayout, resolveLeanToWallPlacement } from './layout' +import { + findLeanToSlabEdgePlacement, + nextLeanToPlacementRotation, + reconcileLeanToSlabEdgePlacement, + resolveLeanToCommitTarget, + resolveLeanToFreestandingPlacement, + resolveLeanToPlanPlacement, + resolveLeanToSlabEdgePlacement, + resolveLeanToWallPlanTarget, +} from './placement' +import { applyLeanToWallAutoSpan } from './roof-attachment' + +describe('lean-to canopy placement', () => { + test('places a freestanding canopy on the active level with two supported sides', () => { + const node = resolveLeanToFreestandingPlacement('level_ground', [4, 6]) + + expect(node).toMatchObject({ + parentId: 'level_ground', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + connectionMode: 'manual', + position: [4, 0, 4.625], + rotation: [0, 0, 0], + }) + expect(node.hostRoofId).toBeUndefined() + }) + + test('keeps the requested rotation for a freestanding placement target', () => { + const target = resolveLeanToPlanPlacement({ + activeLevelId: 'level_ground', + freestandingPoint: [4, 6], + freestandingRotationY: Math.PI / 4, + nodes: {}, + point: [4, 6], + }) + + expect(target.node).toMatchObject({ + hostKind: 'freestanding', + rotation: [0, Math.PI / 4, 0], + }) + }) + + test('places the freestanding footprint center at the requested plan point', () => { + const point: readonly [number, number] = [4, 6] + const rotationY = Math.PI / 4 + const node = resolveLeanToFreestandingPlacement('level_ground', point, rotationY) + const { roofCenterX, roofCenterZ } = resolveLeanToLayout(node) + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + const footprintCenter: [number, number] = [ + node.position[0] + roofCenterX * cos + roofCenterZ * sin, + node.position[2] - roofCenterX * sin + roofCenterZ * cos, + ] + + expect(footprintCenter[0]).toBeCloseTo(point[0], 6) + expect(footprintCenter[1]).toBeCloseTo(point[1], 6) + }) + + test('maps R and T to opposite 45 degree placement rotations', () => { + expect(nextLeanToPlacementRotation(0, 'r')).toBeCloseTo(Math.PI / 4) + expect(nextLeanToPlacementRotation(0, 't')).toBeCloseTo(-Math.PI / 4) + }) + + test('commits the visible ghost when the click ray resolves a different target', () => { + const visibleWallTarget = { kind: 'wall', span: 9 } + const clickRayTarget = { kind: 'freestanding', span: 4 } + + expect(resolveLeanToCommitTarget(visibleWallTarget, clickRayTarget)).toBe(visibleWallTarget) + }) + + test('snaps a ground-plane target near a wall before falling back to freestanding', () => { + const building = BuildingNode.parse({ id: 'building_wall_snap' }) + const wallId = 'wall_snap_target' + const level = LevelNode.parse({ + id: 'level_wall_snap', + parentId: building.id, + level: 0, + height: 3, + children: [wallId], + }) + const wall = WallNode.parse({ + id: wallId, + parentId: level.id, + start: [0, 0], + end: [8, 0], + height: 3, + }) + const nodes = { + [building.id]: building, + [level.id]: level, + [wall.id]: wall, + } as Record + + const target = resolveLeanToPlanPlacement({ + activeLevelId: level.id, + freestandingPoint: [3, 0], + nodes, + point: [3, 0.2], + }) + + expect(target.valid).toBe(true) + expect(target.wall?.id).toBe(wall.id) + expect(target.node).toMatchObject({ + parentId: wall.id, + hostKind: 'wall', + highSideMode: 'wall-ledger', + }) + }) + + test('snaps a ground-plane target near a curved wall before falling back to freestanding', () => { + const building = BuildingNode.parse({ id: 'building_curved_wall_snap' }) + const wallId = 'wall_curved_snap_target' + const level = LevelNode.parse({ + id: 'level_curved_wall_snap', + parentId: building.id, + level: 0, + height: 3, + children: [wallId], + }) + const wall = WallNode.parse({ + id: wallId, + parentId: level.id, + start: [0, 0], + end: [6, 0], + curveOffset: 1, + height: 3, + }) + const nodes = { + [building.id]: building, + [level.id]: level, + [wall.id]: wall, + } as Record + + const target = resolveLeanToPlanPlacement({ + activeLevelId: level.id, + freestandingPoint: [3, -1.1], + nodes, + point: [3, -1.1], + }) + + expect(target.valid).toBe(true) + expect(target.wall?.id).toBe(wall.id) + expect(target.node).toMatchObject({ + parentId: wall.id, + hostKind: 'wall', + highSideMode: 'wall-ledger', + }) + }) + + test('includes a connected curved-wall corner in the wall canopy preview', () => { + const curvedWall = WallNode.parse({ + id: 'wall_preview_curved_corner', + parentId: 'level_preview_corner', + start: [0, 0], + end: [6, 0], + curveOffset: -0.5, + }) + const straightWall = WallNode.parse({ + id: 'wall_preview_straight_corner', + parentId: 'level_preview_corner', + start: [6, 0], + end: [6, -6], + }) + const existing = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(curvedWall, getWallCurveLength(curvedWall) / 2, 'front')!, + curvedWall, + ), + id: 'leanto_preview_existing', + } + const draft = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(straightWall, 3, 'front')!, + straightWall, + ), + id: 'leanto_preview_draft', + } + const nodes = Object.fromEntries( + [curvedWall, straightWall, existing].map((node) => [node.id, node]), + ) as Record + + const target = resolveLeanToWallPlanTarget(straightWall, 3, 'front', nodes) + const joints = readLeanToCornerJointMetadata(target!.node) + + expect(target?.valid).toBe(true) + expect(joints.left?.gutterMitre).toBeCloseTo(0.577309, 5) + expect(joints.left?.seam).toHaveLength(2) + }) + + test('attaches the high edge to an upper slab and keeps posts on the front edge', () => { + const building = BuildingNode.parse({ id: 'building_home' }) + const ground = LevelNode.parse({ + id: 'level_ground', + parentId: building.id, + level: 0, + height: 3, + }) + const first = LevelNode.parse({ + id: 'level_first', + parentId: building.id, + level: 1, + height: 3, + }) + const slab = SlabNode.parse({ + id: 'slab_first_floor', + parentId: first.id, + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + elevation: 0.05, + thickness: 0.2, + }) + const nodes = { + [building.id]: building, + [ground.id]: ground, + [first.id]: first, + [slab.id]: slab, + } as Record + + const node = resolveLeanToSlabEdgePlacement({ + activeLevelId: ground.id, + edgeIndex: 0, + edgeT: 0.5, + nodes, + slab, + }) + + expect(node).toMatchObject({ + parentId: ground.id, + hostKind: 'slab-edge', + hostSlabId: slab.id, + hostSlabEdgeIndex: 0, + hostSlabEdgeT: 0.5, + highSideMode: 'wall-ledger', + connectionMode: 'manual', + position: [3, 0, 0], + rotation: [0, Math.PI, 0], + span: 5.9, + }) + expect(node?.highEdgeHeight).toBeCloseTo(2.85, 6) + expect(node?.hostRoofId).toBeUndefined() + }) + + test('finds the nearest eligible upper slab edge from a plan point', () => { + const building = BuildingNode.parse({ id: 'building_edge_search' }) + const ground = LevelNode.parse({ + id: 'level_edge_search_ground', + parentId: building.id, + level: 0, + height: 3, + }) + const first = LevelNode.parse({ + id: 'level_edge_search_first', + parentId: building.id, + level: 1, + height: 3, + }) + const slab = SlabNode.parse({ + id: 'slab_edge_search', + parentId: first.id, + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + elevation: 0.05, + thickness: 0.2, + }) + const nodes = { + [building.id]: building, + [ground.id]: ground, + [first.id]: first, + [slab.id]: slab, + } as Record + + const node = findLeanToSlabEdgePlacement([5.9, 2], nodes, ground.id) + + expect(node).toMatchObject({ + hostSlabId: slab.id, + hostSlabEdgeIndex: 1, + hostSlabEdgeT: 0.5, + position: [6, 0, 2], + rotation: [0, Math.PI / 2, 0], + span: 3.9, + }) + }) + + test('keeps a slab-attached canopy aligned when its host slab changes', () => { + const building = BuildingNode.parse({ id: 'building_slab_tracking' }) + const ground = LevelNode.parse({ + id: 'level_slab_tracking_ground', + parentId: building.id, + level: 0, + height: 3, + }) + const first = LevelNode.parse({ + id: 'level_slab_tracking_first', + parentId: building.id, + level: 1, + height: 3, + }) + const originalSlab = SlabNode.parse({ + id: 'slab_tracking', + parentId: first.id, + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + elevation: 0.05, + thickness: 0.2, + }) + const originalNodes = { + [building.id]: building, + [ground.id]: ground, + [first.id]: first, + [originalSlab.id]: originalSlab, + } as Record + const canopy = resolveLeanToSlabEdgePlacement({ + activeLevelId: ground.id, + edgeIndex: 0, + edgeT: 0.5, + nodes: originalNodes, + slab: originalSlab, + })! + const changedSlab = { + ...originalSlab, + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ] as [number, number][], + elevation: 0.15, + } + const changedNodes = { + ...originalNodes, + [changedSlab.id]: changedSlab, + [canopy.id]: canopy, + } as Record + + const reconciled = reconcileLeanToSlabEdgePlacement(canopy, changedNodes) + + expect(reconciled).toMatchObject({ + position: [4, 0, 0], + span: 7.9, + rotation: [0, Math.PI, 0], + }) + expect(reconciled.highEdgeHeight).toBeCloseTo(2.95, 6) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/placement.ts b/packages/nodes/src/lean-to-extension/placement.ts new file mode 100644 index 0000000000..e7aaa8b301 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement.ts @@ -0,0 +1,334 @@ +import { + type AnyNode, + type AnyNodeId, + getLevelElevations, + LeanToExtensionNode, + type SlabNode, + type WallNode, +} from '@pascal-app/core' +import { findClosestWallAttachmentInPlan } from '../shared/wall-attach-target' +import { + LEAN_TO_CORNER_JOINTS_KEY, + leanToCornerJointMetadata, + resolveLeanToCornerJoints, +} from './corner-joint' +import { leanToLowEdgeHeight, resolveLeanToPlanCenter, resolveLeanToWallPlacement } from './layout' +import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' +import { + applyLeanToAvailableWallSpan, + applyLeanToRoofAttachment, + applyLeanToWallAutoSpan, + clearLeanToRoofAttachment, + resolveLeanToRoofAttachment, +} from './roof-attachment' + +export type LeanToPlanPlacementTarget = { + node: LeanToExtensionNode + valid: boolean + wall?: WallNode +} + +export function resolveLeanToCommitTarget( + visibleTarget: T | null, + clickTarget: T | null, +): T | null { + return visibleTarget ?? clickTarget +} + +/** Apply transient corner data so the placement ghost matches the committed assembly. */ +export function resolveLeanToPreviewNode( + node: LeanToExtensionNode, + wall: WallNode | undefined, + nodes: Record, +): LeanToExtensionNode { + if (!wall) return node + const joints = resolveLeanToCornerJoints(node, wall, nodes) + if (Object.keys(joints).length === 0) return node + return { + ...node, + leftEndCondition: joints.left ? 'joined' : node.leftEndCondition, + rightEndCondition: joints.right ? 'joined' : node.rightEndCondition, + metadata: { + ...(node.metadata && typeof node.metadata === 'object' ? node.metadata : {}), + [LEAN_TO_CORNER_JOINTS_KEY]: leanToCornerJointMetadata(joints), + }, + } +} + +export function resolveLeanToWallPlanTarget( + wall: WallNode, + localX: number, + side: 'front' | 'back', + nodes: Record, +): LeanToPlanPlacementTarget | null { + const wallPlacement = resolveLeanToWallPlacement(wall, localX, side) + if (!wallPlacement) return null + + const attachment = resolveLeanToRoofAttachment(wallPlacement, wall, nodes) + const autoSpannedNode = attachment + ? applyLeanToRoofAttachment(wallPlacement, attachment) + : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), wall) + const attachedNode = applyLeanToAvailableWallSpan( + autoSpannedNode, + wall, + nodes, + wallPlacement.position[0], + ) + const node = resolveLeanToEndAbutments(attachedNode, wall, nodes) + const previewNode = resolveLeanToPreviewNode(node, wall, nodes) + return { + node: previewNode, + valid: leanToPlacementConflicts(node, wall, nodes).length === 0, + wall, + } +} + +const PLACEMENT_ROTATION_STEP = Math.PI / 4 + +export function nextLeanToPlacementRotation( + current: number, + key: string, + hasShortcutModifier = false, +): number { + if (hasShortcutModifier) return current + const direction = key === 'r' || key === 'R' ? 1 : key === 't' || key === 'T' ? -1 : 0 + if (direction === 0) return current + return (Math.round(current / PLACEMENT_ROTATION_STEP) + direction) * PLACEMENT_ROTATION_STEP +} + +export function resolveLeanToPlanPosition( + node: LeanToExtensionNode, + point: readonly [number, number], +): LeanToExtensionNode['position'] { + const [centerX, centerZ] = resolveLeanToPlanCenter(node) + const rotationY = node.rotation[1] + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + return [ + point[0] - centerX * cos - centerZ * sin, + node.position[1], + point[1] + centerX * sin - centerZ * cos, + ] +} + +export function resolveLeanToFreestandingPlacement( + levelId: string, + point: readonly [number, number], + rotationY = 0, +): LeanToExtensionNode { + const parsed = LeanToExtensionNode.parse({ + name: 'Freestanding Lean-to Canopy', + parentId: levelId, + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + connectionMode: 'manual', + autoSpan: false, + position: [0, 0, 0], + rotation: [0, rotationY, 0], + }) + return { + ...parsed, + position: resolveLeanToPlanPosition(parsed, point), + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } +} + +export function resolveLeanToPlanPlacement({ + activeLevelId, + freestandingPoint, + freestandingRotationY = 0, + nodes, + point, +}: { + activeLevelId: AnyNodeId + freestandingPoint: readonly [number, number] + freestandingRotationY?: number + nodes: Record + point: readonly [number, number] +}): LeanToPlanPlacementTarget { + const hit = findClosestWallAttachmentInPlan(point, nodes, activeLevelId) + if (hit) { + const target = resolveLeanToWallPlanTarget(hit.wall, hit.localX, hit.side, nodes) + if (target) return target + } + + const slabAttached = findLeanToSlabEdgePlacement(point, nodes, activeLevelId) + if (slabAttached) return { node: slabAttached, valid: true } + + return { + node: resolveLeanToFreestandingPlacement( + activeLevelId, + freestandingPoint, + freestandingRotationY, + ), + valid: true, + } +} + +export function resolveLeanToSlabEdgePlacement({ + activeLevelId, + edgeIndex, + edgeT, + nodes, + slab, +}: { + activeLevelId: string + edgeIndex: number + edgeT: number + nodes: Record + slab: SlabNode +}): LeanToExtensionNode | null { + const activeLevel = getLevelElevations(nodes).get(activeLevelId) + const hostLevel = slab.parentId ? getLevelElevations(nodes).get(slab.parentId) : undefined + if (!(activeLevel && hostLevel && activeLevel.buildingId === hostLevel.buildingId)) return null + + const start = slab.polygon[edgeIndex] + const end = slab.polygon[(edgeIndex + 1) % slab.polygon.length] + if (!(start && end)) return null + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const edgeLength = Math.hypot(dx, dz) + if (edgeLength < 0.6) return null + + const t = Math.max(0, Math.min(1, edgeT)) + const area = slab.polygon.reduce((sum, point, index) => { + const next = slab.polygon[(index + 1) % slab.polygon.length]! + return sum + point[0] * next[1] - next[0] * point[1] + }, 0) + const winding = area >= 0 ? 1 : -1 + const outwardX = (winding * dz) / edgeLength + const outwardZ = (-winding * dx) / edgeLength + const highEdgeHeight = hostLevel.baseY - activeLevel.baseY + slab.elevation - slab.thickness + if (highEdgeHeight < 0.8 || highEdgeHeight > 10) return null + + const parsed = LeanToExtensionNode.parse({ + name: 'Slab-attached Lean-to Canopy', + parentId: activeLevelId, + hostKind: 'slab-edge', + hostSlabId: slab.id, + hostSlabEdgeIndex: edgeIndex, + hostSlabEdgeT: t, + highSideMode: 'wall-ledger', + connectionMode: 'manual', + autoSpan: true, + span: Math.max(0.5, edgeLength - 0.1), + position: [start[0] + dx * t, 0, start[1] + dz * t], + rotation: [0, Math.atan2(outwardX, outwardZ), 0], + highEdgeHeight, + }) + return { + ...parsed, + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + lowEdgeHeight: leanToLowEdgeHeight(parsed), + } +} + +export function findLeanToSlabEdgePlacement( + point: readonly [number, number], + nodes: Record, + activeLevelId: string, + maxDistance = 0.35, +): LeanToExtensionNode | null { + let best: { distance: number; node: LeanToExtensionNode } | null = null + for (const candidate of Object.values(nodes)) { + if (candidate.type !== 'slab' || candidate.recessed || candidate.polygon.length < 2) continue + for (let edgeIndex = 0; edgeIndex < candidate.polygon.length; edgeIndex++) { + const start = candidate.polygon[edgeIndex]! + const end = candidate.polygon[(edgeIndex + 1) % candidate.polygon.length]! + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSq = dx * dx + dz * dz + if (lengthSq <= 1e-9) continue + const edgeT = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSq), + ) + const edgeX = start[0] + dx * edgeT + const edgeZ = start[1] + dz * edgeT + const distance = Math.hypot(point[0] - edgeX, point[1] - edgeZ) + if (distance > maxDistance || (best && distance >= best.distance)) continue + const node = resolveLeanToSlabEdgePlacement({ + activeLevelId, + edgeIndex, + edgeT, + nodes, + slab: candidate, + }) + if (node) best = { distance, node } + } + } + return best?.node ?? null +} + +export function reconcileLeanToSlabEdgePlacement( + node: LeanToExtensionNode, + nodes: Record, +): LeanToExtensionNode { + if ( + node.hostKind !== 'slab-edge' || + !node.parentId || + !node.hostSlabId || + node.hostSlabEdgeIndex === undefined || + node.hostSlabEdgeT === undefined + ) { + return node + } + const slab = nodes[node.hostSlabId as AnyNodeId] + if (slab?.type !== 'slab') return node + const resolved = resolveLeanToSlabEdgePlacement({ + activeLevelId: node.parentId, + edgeIndex: node.hostSlabEdgeIndex, + edgeT: node.hostSlabEdgeT, + nodes, + slab, + }) + if (!resolved) return node + const highEdgeHeight = resolved.highEdgeHeight + node.hostHeightOffset + return { + ...node, + position: resolved.position, + rotation: resolved.rotation, + span: node.autoSpan ? resolved.span : node.span, + highEdgeHeight, + lowEdgeHeight: leanToLowEdgeHeight({ ...node, highEdgeHeight }), + highSideMode: 'wall-ledger', + connectionMode: 'manual', + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } +} + +export function moveLeanToAlongSlabEdge( + node: LeanToExtensionNode, + point: readonly [number, number], + nodes: Record, +): LeanToExtensionNode | null { + if (node.hostKind !== 'slab-edge' || !node.hostSlabId || node.hostSlabEdgeIndex === undefined) { + return null + } + const slab = nodes[node.hostSlabId as AnyNodeId] + if (slab?.type !== 'slab') return null + const start = slab.polygon[node.hostSlabEdgeIndex] + const end = slab.polygon[(node.hostSlabEdgeIndex + 1) % slab.polygon.length] + if (!(start && end)) return null + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSq = dx * dx + dz * dz + if (lengthSq <= 1e-9) return null + const hostSlabEdgeT = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSq), + ) + return reconcileLeanToSlabEdgePlacement({ ...node, hostSlabEdgeT }, nodes) +} diff --git a/packages/nodes/src/lean-to-extension/post-omissions.test.ts b/packages/nodes/src/lean-to-extension/post-omissions.test.ts new file mode 100644 index 0000000000..81061a772d --- /dev/null +++ b/packages/nodes/src/lean-to-extension/post-omissions.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + ColumnNode, + LeanToExtensionNode, + type LeanToExtensionNode as LeanToNode, +} from '@pascal-app/core' +import { isLeanToPostOmitted, leanToPostOmissionPatchesOnDelete } from './post-omissions' + +describe('isLeanToPostOmitted', () => { + test('treats a legacy node without omission data as having no omitted posts', () => { + const parsed = LeanToExtensionNode.parse({ + postLayoutMode: 'count', + postCount: 3, + }) + const { omittedPostSlots: _, ...legacy } = parsed + + expect(isLeanToPostOmitted(legacy as LeanToNode, 'low', 1)).toBe(false) + }) + + test('records the first omission on a legacy node without omission data', () => { + const parsed = LeanToExtensionNode.parse({ + postLayoutMode: 'count', + postCount: 3, + }) + const { omittedPostSlots: _, ...legacy } = parsed + const post = ColumnNode.parse({ + parentId: parsed.id, + metadata: { + leanToRole: 'post', + managedByLeanTo: parsed.id, + leanToPostIndex: 1, + leanToPostSide: 'low', + }, + }) + const nodes = { + [parsed.id]: legacy, + [post.id]: post, + } as unknown as Record + + expect(leanToPostOmissionPatchesOnDelete(post, nodes)).toEqual([ + { + id: parsed.id, + data: { + omittedPostSlots: [{ side: 'low', index: 1, layoutCount: 3 }], + }, + }, + ]) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/post-omissions.ts b/packages/nodes/src/lean-to-extension/post-omissions.ts new file mode 100644 index 0000000000..876f069e05 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/post-omissions.ts @@ -0,0 +1,58 @@ +import type { AnyNode, AnyNodeId, ColumnNode, LeanToExtensionNode } from '@pascal-app/core' +import type { LeanToPostSide } from './assembly' +import { resolveLeanToLayout } from './layout' + +function managedPostSlot(column: ColumnNode): { side: LeanToPostSide; index: number } | null { + const metadata = column.metadata + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + if (metadata.leanToRole !== 'post' || typeof metadata.managedByLeanTo !== 'string') return null + if (typeof metadata.leanToPostIndex !== 'number' || !Number.isInteger(metadata.leanToPostIndex)) { + return null + } + return { + side: metadata.leanToPostSide === 'high' ? 'high' : 'low', + index: metadata.leanToPostIndex, + } +} + +export function isLeanToPostOmitted( + leanTo: LeanToExtensionNode, + side: LeanToPostSide, + index: number, +): boolean { + const currentCount = resolveLeanToLayout(leanTo).postXs.length + return (leanTo.omittedPostSlots ?? []).some((slot) => { + if (slot.side !== side) return false + if (slot.index < 0 || index < 0) return slot.index === index + if (leanTo.hostKind === 'conical-roof') { + const normalized = slot.index / Math.max(1, slot.layoutCount) + return Math.round(normalized * currentCount) % currentCount === index + } + const normalized = slot.index / Math.max(1, slot.layoutCount - 1) + return Math.round(normalized * Math.max(1, currentCount - 1)) === index + }) +} + +export function leanToPostOmissionPatchesOnDelete( + column: ColumnNode, + nodes: Record, +): Array<{ id: AnyNodeId; data: Partial }> { + const slot = managedPostSlot(column) + if (!slot) return [] + const metadata = column.metadata as Record + const leanTo = nodes[metadata.managedByLeanTo as AnyNodeId] + if (leanTo?.type !== 'lean-to-extension' || isLeanToPostOmitted(leanTo, slot.side, slot.index)) { + return [] + } + return [ + { + id: leanTo.id as AnyNodeId, + data: { + omittedPostSlots: [ + ...(leanTo.omittedPostSlots ?? []), + { ...slot, layoutCount: resolveLeanToLayout(leanTo).postXs.length }, + ], + }, + }, + ] +} diff --git a/packages/nodes/src/lean-to-extension/preview-geometry.test.ts b/packages/nodes/src/lean-to-extension/preview-geometry.test.ts new file mode 100644 index 0000000000..042992734e --- /dev/null +++ b/packages/nodes/src/lean-to-extension/preview-geometry.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test' +import { LeanToExtensionNode, RoofSegmentNode } from '@pascal-app/core' +import { Mesh, MeshBasicMaterial } from 'three' +import { resolveConicalLeanToPlacement } from './conical-host' +import { buildLeanToExtensionGeometry } from './geometry' +import { + buildLeanToExtensionPreviewGeometry, + disposeLeanToExtensionPreviewGeometry, + LEAN_TO_GHOST_COLOR, + LEAN_TO_INVALID_GHOST_COLOR, +} from './preview-geometry' + +function previewMaterial(root: ReturnType) { + const mesh = root.children.find((child): child is Mesh => child instanceof Mesh) + expect(mesh).toBeDefined() + expect(mesh?.material).toBeInstanceOf(MeshBasicMaterial) + return mesh?.material as MeshBasicMaterial +} + +describe('lean-to placement ghost', () => { + test('uses the same placement geometry as the committed canopy', () => { + const node = LeanToExtensionNode.parse({ + highSideMode: 'independent-high-beam', + postCount: 5, + }) + const committedGeometry = buildLeanToExtensionGeometry(node) + const root = buildLeanToExtensionPreviewGeometry(node) + const meshes: Mesh[] = [] + const committedMeshes: Mesh[] = [] + root.traverse((object) => { + if (object instanceof Mesh) meshes.push(object) + }) + committedGeometry.traverse((object) => { + if (object instanceof Mesh) committedMeshes.push(object) + }) + + expect(meshes.map((mesh) => mesh.name).sort()).toEqual( + committedMeshes.map((mesh) => mesh.name).sort(), + ) + expect(new Set(meshes.map((mesh) => mesh.material)).size).toBe(1) + const material = previewMaterial(root) + expect(material.color.getHex()).toBe(LEAN_TO_GHOST_COLOR) + expect(material.depthWrite).toBe(false) + expect(material.opacity).toBe(0.3) + expect(material.transparent).toBe(true) + + disposeLeanToExtensionPreviewGeometry(root) + committedGeometry.traverse((object) => { + if (object instanceof Mesh) object.geometry.dispose() + }) + }) + + test('uses the same geometry with an invalid red material', () => { + const root = buildLeanToExtensionPreviewGeometry(LeanToExtensionNode.parse({}), true) + + const material = previewMaterial(root) + expect(material.color.getHex()).toBe(LEAN_TO_INVALID_GHOST_COLOR) + expect(material.depthWrite).toBe(false) + expect(material.opacity).toBe(0.38) + expect(material.transparent).toBe(true) + + disposeLeanToExtensionPreviewGeometry(root) + }) + + test('keeps a conical hover ghost visible over its host surface', () => { + const host = RoofSegmentNode.parse({ + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 3, + }) + const node = resolveConicalLeanToPlacement(host)! + const root = buildLeanToExtensionPreviewGeometry(node) + + expect(root.children.length).toBeGreaterThan(1) + expect(previewMaterial(root).depthTest).toBe(false) + + disposeLeanToExtensionPreviewGeometry(root) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/preview-geometry.ts b/packages/nodes/src/lean-to-extension/preview-geometry.ts new file mode 100644 index 0000000000..e467fdb255 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/preview-geometry.ts @@ -0,0 +1,42 @@ +import type { LeanToExtensionNode } from '@pascal-app/core' +import { type Group, type Material, Mesh, MeshBasicMaterial } from 'three' +import { buildLeanToExtensionGeometry } from './geometry' + +export const LEAN_TO_GHOST_COLOR = 0x6c_a3_ff +export const LEAN_TO_INVALID_GHOST_COLOR = 0xef_44_44 + +export function buildLeanToExtensionPreviewGeometry( + node: LeanToExtensionNode, + invalid = false, +): Group { + const group = buildLeanToExtensionGeometry(node, undefined, 'rendered', false) + group.name = 'lean-to-extension-preview' + const material = new MeshBasicMaterial({ + color: invalid ? LEAN_TO_INVALID_GHOST_COLOR : LEAN_TO_GHOST_COLOR, + depthTest: false, + depthWrite: false, + opacity: invalid ? 0.38 : 0.3, + transparent: true, + }) + const replacedMaterials = new Set() + group.traverse((object) => { + if (!(object instanceof Mesh)) return + const materials = Array.isArray(object.material) ? object.material : [object.material] + for (const source of materials) replacedMaterials.add(source) + object.material = material + }) + for (const source of replacedMaterials) source.dispose() + + return group +} + +export function disposeLeanToExtensionPreviewGeometry(root: Group): void { + const materials = new Set() + root.traverse((object) => { + if (!(object instanceof Mesh)) return + object.geometry.dispose() + const meshMaterials = Array.isArray(object.material) ? object.material : [object.material] + for (const material of meshMaterials) materials.add(material) + }) + for (const material of materials) material.dispose() +} diff --git a/packages/nodes/src/lean-to-extension/preview.tsx b/packages/nodes/src/lean-to-extension/preview.tsx index 424669095c..53f3c4b7b6 100644 --- a/packages/nodes/src/lean-to-extension/preview.tsx +++ b/packages/nodes/src/lean-to-extension/preview.tsx @@ -2,45 +2,34 @@ 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' +import { + buildLeanToExtensionPreviewGeometry, + disposeLeanToExtensionPreviewGeometry, +} from './preview-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) => { +const LeanToExtensionPreview = ({ + node, + invalid, +}: { + node: LeanToExtensionNode + invalid?: boolean +}) => { + const built = useMemo(() => { + const next = buildLeanToExtensionPreviewGeometry(node, invalid) + next.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) + object.raycast = () => {} }) - return () => { - for (const material of ownedMaterials) material.dispose() - built.traverse((object) => { - const mesh = object as { geometry?: { dispose: () => void } } - mesh.geometry?.dispose() - }) - } - }, [built]) + return next + }, [invalid, node]) + + useEffect( + () => () => { + disposeLeanToExtensionPreviewGeometry(built) + }, + [built], + ) return } diff --git a/packages/nodes/src/lean-to-extension/renderer.tsx b/packages/nodes/src/lean-to-extension/renderer.tsx index 6e5f6a212a..b8a3ebfa00 100644 --- a/packages/nodes/src/lean-to-extension/renderer.tsx +++ b/packages/nodes/src/lean-to-extension/renderer.tsx @@ -31,6 +31,7 @@ const LeanToExtensionRenderer = ({ node }: { node: LeanToExtensionNode }) => { const overridePosition = liveOverride?.position as [number, number, number] | undefined const overrideRotation = liveOverride?.rotation as [number, number, number] | undefined + const overrideVisible = liveOverride?.visible const effectiveNode: LeanToExtensionNode = { ...node, position: liveTransform?.position ?? overridePosition ?? node.position, @@ -50,7 +51,9 @@ const LeanToExtensionRenderer = ({ node }: { node: LeanToExtensionNode }) => { position={pose.position} ref={ref} rotation={[effectiveNode.rotation[0], pose.rotationY, effectiveNode.rotation[2]]} - visible={effectiveNode.visible !== false} + visible={ + typeof overrideVisible === 'boolean' ? overrideVisible : effectiveNode.visible !== false + } {...handlers} > {effectiveNode.children.map((childId) => ( diff --git a/packages/nodes/src/lean-to-extension/roof-attachment.ts b/packages/nodes/src/lean-to-extension/roof-attachment.ts index 2d89edda9e..c029a9dc03 100644 --- a/packages/nodes/src/lean-to-extension/roof-attachment.ts +++ b/packages/nodes/src/lean-to-extension/roof-attachment.ts @@ -366,6 +366,39 @@ export function applyLeanToWallAutoSpan( } } +export function applyLeanToWallCornerSpan( + leanTo: LeanToExtensionNode, + wall: WallNode, +): LeanToExtensionNode { + if (!leanTo.autoMiterCorners) return leanTo + const wallLength = isCurvedWall(wall) + ? getWallCurveLength(wall) + : Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + if (wallLength <= 1e-6) return leanTo + if (leanTo.span <= wallLength + 1e-6) return leanTo + + const leftOverhang = Math.max(0, leanTo.leftOverhang) + const rightOverhang = Math.max(0, leanTo.rightOverhang) + const currentStart = leanTo.position[0] - leanTo.span / 2 - leftOverhang + const currentEnd = leanTo.position[0] + leanTo.span / 2 + rightOverhang + const targetStart = Math.max(0, currentStart) + const targetEnd = Math.min(wallLength, currentEnd) + const visibleSpan = targetEnd - targetStart + if (currentStart >= -1e-6 && currentEnd <= wallLength + 1e-6) { + return leanTo + } + if (visibleSpan < MIN_EXTENSION_SPAN + leftOverhang + rightOverhang) return leanTo + + return { + ...leanTo, + ...autoSpanPatch( + leanTo, + visibleSpan, + targetStart + (visibleSpan + leftOverhang - rightOverhang) / 2, + ), + } +} + export function applyLeanToAvailableWallSpan( leanTo: LeanToExtensionNode, wall: WallNode, diff --git a/packages/nodes/src/lean-to-extension/roof-corner.test.ts b/packages/nodes/src/lean-to-extension/roof-corner.test.ts index 732a9897e4..774d036de2 100644 --- a/packages/nodes/src/lean-to-extension/roof-corner.test.ts +++ b/packages/nodes/src/lean-to-extension/roof-corner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, getRoofSegmentSurfaceY, + getWallArcData, getWallCurveLength, LeanToExtensionNode, WallNode, @@ -14,7 +15,7 @@ import { bendLocalPoint } from './arc' import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' import { resolveLeanToCornerJoints } from './corner-joint' import { leanToWallLocalPose, resolveLeanToWallPlacement } from './layout' -import { applyLeanToWallAutoSpan } from './roof-attachment' +import { applyLeanToWallAutoSpan, applyLeanToWallCornerSpan } from './roof-attachment' function cornerFixture(reverseWalls = false, sideOverhang = 0) { const wallA = WallNode.parse({ @@ -217,7 +218,7 @@ function getSegmentSlopeFrameForTest(segment: ReturnType, leanTo: ReturnType, @@ -687,6 +707,399 @@ describe('lean-to corner joint', () => { for (const mesh of expectedMeshes) mesh.geometry.dispose() }) + test('joins a 105 degree straight canopy to a semicircular canopy using endpoint tangents', () => { + const curvedWall = WallNode.parse({ + id: 'wall_semicircle_105_curve', + parentId: 'level_semicircle_105', + start: [0, 0], + end: [6, 0], + curveOffset: -3, + }) + const straightWall = WallNode.parse({ + id: 'wall_semicircle_105_straight', + parentId: 'level_semicircle_105', + start: [6, 0], + end: [0.2044450422655899, -1.552914270615125], + }) + const curved = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(curvedWall, getWallCurveLength(curvedWall) / 2, 'front')!, + curvedWall, + ), + id: 'leanto_semicircle_105_curve', + } + const straight = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(straightWall, getWallCurveLength(straightWall) / 2, 'front')!, + straightWall, + ), + id: 'leanto_semicircle_105_straight', + } + const nodes = Object.fromEntries( + [curvedWall, straightWall, curved, straight].map((node) => [node.id, node]), + ) as Record + + const curvedJoint = resolveLeanToCornerJoints(curved, curvedWall, nodes).right + const straightJoint = resolveLeanToCornerJoints(straight, straightWall, nodes).left + + expect(curvedJoint?.neighborId).toBe(straight.id) + expect(straightJoint?.neighborId).toBe(curved.id) + expect(curvedJoint?.seam).toHaveLength(2) + expect(straightJoint?.seam).toHaveLength(2) + + const curvedAssembly = createLeanToAssembly(curved, undefined, nodes) + const straightAssembly = createLeanToAssembly(straight, undefined, nodes) + const curvedGeometry = generateRoofSegmentGeometry(curvedAssembly.segment).applyMatrix4( + segmentWorldMatrix(curvedWall, curved, curvedAssembly.segment), + ) + const straightGeometry = generateRoofSegmentGeometry(straightAssembly.segment).applyMatrix4( + segmentWorldMatrix(straightWall, straight, straightAssembly.segment), + ) + + expect(closestMeshDistance(curvedGeometry, straightGeometry)).toBeLessThan(0.05) + + curvedGeometry.dispose() + straightGeometry.dispose() + }) + + test('connects three consecutive curved-straight-curved canopies through both ends', () => { + const wallA = WallNode.parse({ + id: 'wall_chain_curved_a', + parentId: 'level_chain', + start: [0, 0], + end: [6, 0], + curveOffset: -0.5, + }) + const wallB = WallNode.parse({ + id: 'wall_chain_straight', + parentId: 'level_chain', + start: [6, 0], + end: [6, -6], + }) + const wallC = WallNode.parse({ + id: 'wall_chain_curved_c', + parentId: 'level_chain', + start: [6, -6], + end: [12, -6], + curveOffset: -0.5, + }) + const leanToA = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(wallA, getWallCurveLength(wallA) / 2, 'front')!, + wallA, + ), + id: 'leanto_chain_curved_a', + } + const overlongLeanToB = { + ...applyLeanToWallAutoSpan(resolveLeanToWallPlacement(wallB, 3, 'front')!, wallB), + id: 'leanto_chain_straight', + autoSpan: false, + span: 7, + highEdgeHeight: 3.1, + pitch: 16, + } + const leanToB = applyLeanToWallCornerSpan(overlongLeanToB, wallB) + const leanToC = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(wallC, getWallCurveLength(wallC) / 2, 'front')!, + wallC, + ), + id: 'leanto_chain_curved_c', + } + const nodes = Object.fromEntries( + [wallA, wallB, wallC, leanToA, leanToB, leanToC].map((node) => [node.id, node]), + ) as Record + + expect(leanToB.span).toBeCloseTo(5.7, 8) + expect(leanToB.position[0]).toBeCloseTo(3, 8) + + const jointsB = resolveLeanToCornerJoints(leanToB, wallB, nodes) + const jointsC = resolveLeanToCornerJoints(leanToC, wallC, nodes) + const seamAB = jointsB.left?.seam?.map((point) => cornerPlanPointToWorld(wallB, leanToB, point)) + const seamBC = jointsB.right?.seam?.map((point) => + cornerPlanPointToWorld(wallB, leanToB, point), + ) + const reciprocalSeamBC = jointsC.left?.seam?.map((point) => + cornerPlanPointToWorld(wallC, leanToC, point), + ) + + expect(jointsB.left?.neighborId).toBe(leanToA.id) + expect(jointsB.right?.neighborId).toBe(leanToC.id) + expect(jointsC.left?.neighborId).toBe(leanToB.id) + expect(seamAB).toHaveLength(2) + expect(seamBC).toHaveLength(2) + expect(reciprocalSeamBC).toHaveLength(2) + expect(pointSetHausdorffDistance(seamBC!, reciprocalSeamBC!)).toBeLessThan(1e-5) + expect(jointsB.left?.roofPiece.length).toBeGreaterThanOrEqual(3) + expect(jointsB.right?.roofPiece.length).toBeGreaterThanOrEqual(3) + + const assemblyB = createLeanToAssembly(leanToB, undefined, nodes) + const assemblyC = createLeanToAssembly(leanToC, undefined, nodes) + const geometryB = generateRoofSegmentGeometry(assemblyB.segment).applyMatrix4( + segmentWorldMatrix(wallB, leanToB, assemblyB.segment), + ) + const geometryC = generateRoofSegmentGeometry(assemblyC.segment).applyMatrix4( + segmentWorldMatrix(wallC, leanToC, assemblyC.segment), + ) + expect(closestMeshDistance(geometryB, geometryC)).toBeLessThan(0.06) + expect(jointsB.right?.roofExtension).toBe(0) + expect(jointsC.left?.roofExtension).toBe(0) + expect(jointsB.right?.gutterMitre).toBeCloseTo(jointsC.left?.gutterMitre ?? 0, 8) + geometryB.dispose() + geometryC.dispose() + }) + + test('keeps both joins of a fully inward curved middle canopy connected', () => { + const wallA = WallNode.parse({ + id: 'wall_inward_chain_left', + parentId: 'level_inward_chain', + start: [-4, -4], + end: [0, 0], + }) + const wallB = WallNode.parse({ + id: 'wall_inward_chain_center', + parentId: 'level_inward_chain', + start: [0, 0], + end: [6, 0], + curveOffset: 3, + }) + const wallC = WallNode.parse({ + id: 'wall_inward_chain_right', + parentId: 'level_inward_chain', + start: [6, 0], + end: [10, -4], + }) + const leanToA = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(wallA, getWallCurveLength(wallA) / 2, 'back')!, + wallA, + ), + id: 'leanto_inward_chain_left', + } + const leanToB = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(wallB, getWallCurveLength(wallB) / 2, 'back')!, + wallB, + ), + id: 'leanto_inward_chain_center', + } + const leanToC = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(wallC, getWallCurveLength(wallC) / 2, 'back')!, + wallC, + ), + id: 'leanto_inward_chain_right', + } + const nodes = Object.fromEntries( + [wallA, wallB, wallC, leanToA, leanToB, leanToC].map((node) => [node.id, node]), + ) as Record + + const jointsA = resolveLeanToCornerJoints(leanToA, wallA, nodes) + const jointsB = resolveLeanToCornerJoints(leanToB, wallB, nodes) + const jointsC = resolveLeanToCornerJoints(leanToC, wallC, nodes) + + expect([jointsB.left?.neighborId, jointsB.right?.neighborId].sort()).toEqual( + [leanToA.id, leanToC.id].sort(), + ) + expect(jointsB.left?.roofPiece.length).toBeGreaterThanOrEqual(3) + expect(jointsB.right?.roofPiece.length).toBeGreaterThanOrEqual(3) + + const reciprocalFor = (joints: ReturnType) => + Object.values(joints).find((joint) => joint?.neighborId === leanToB.id) + for (const [own, reciprocal, ownWall, ownLeanTo, reciprocalWall, reciprocalLeanTo] of [ + [jointsB.right, reciprocalFor(jointsA), wallB, leanToB, wallA, leanToA], + [jointsB.left, reciprocalFor(jointsC), wallB, leanToB, wallC, leanToC], + ] as const) { + const ownSeam = own?.seam?.map((point) => cornerPlanPointToWorld(ownWall, ownLeanTo, point)) + const reciprocalSeam = reciprocal?.seam?.map((point) => + cornerPlanPointToWorld(reciprocalWall, reciprocalLeanTo, point), + ) + expect(ownSeam).toHaveLength(2) + expect(reciprocalSeam).toHaveLength(2) + expect(pointSetHausdorffDistance(ownSeam!, reciprocalSeam!)).toBeLessThan(1e-5) + } + + const centerAssembly = createLeanToAssembly(leanToB, undefined, nodes) + expect(centerAssembly.segment.shedFootprintPieces!.length).toBeGreaterThan(1) + const eavePoints = centerAssembly.segment + .shedFootprintPieces!.flat() + .filter((point) => point[1] > 1) + expect(Math.min(...eavePoints.map((point) => point[0]))).toBeLessThan(-1) + expect(Math.max(...eavePoints.map((point) => point[0]))).toBeGreaterThan(1) + + const assemblies = [ + createLeanToAssembly(leanToA, undefined, nodes), + centerAssembly, + createLeanToAssembly(leanToC, undefined, nodes), + ] + const walls = [wallA, wallB, wallC] + const leanTos = [leanToA, leanToB, leanToC] + const roofMeshes = assemblies.map( + (assembly, index) => + new THREE.Mesh( + generateRoofSegmentGeometry(assembly.segment).applyMatrix4( + segmentWorldMatrix(walls[index]!, leanTos[index]!, assembly.segment), + ), + ), + ) + const untrimmedMeshes = assemblies.map( + (assembly, index) => + new THREE.Mesh( + generateRoofSegmentGeometry({ + ...assembly.segment, + shedFootprintPieces: [], + }).applyMatrix4(segmentWorldMatrix(walls[index]!, leanTos[index]!, assembly.segment)), + ), + ) + const bounds = untrimmedMeshes.reduce( + (box, mesh) => box.union(new THREE.Box3().setFromObject(mesh)), + new THREE.Box3(), + ) + const curvedWallArc = getWallArcData(wallB)! + const curvedHostFaceRadius = Math.abs(leanToB.spanArcCenterZ!) + const raycaster = new THREE.Raycaster() + raycaster.ray.direction.set(0, -1, 0) + let gaps = 0 + let overlaps = 0 + for (let x = bounds.min.x + 0.031; x < bounds.max.x; x += 0.1) { + for (let z = bounds.min.z + 0.057; z < bounds.max.z; z += 0.1) { + if ( + Math.hypot(x - curvedWallArc.center.x, z - curvedWallArc.center.y) < curvedHostFaceRadius + ) { + continue + } + raycaster.ray.origin.set(x, 10, z) + if (!untrimmedMeshes.some((mesh) => raycaster.intersectObject(mesh, false).length > 0)) { + continue + } + const owners = roofMeshes.filter( + (mesh) => raycaster.intersectObject(mesh, false).length > 0, + ).length + if (owners === 0) gaps += 1 + if (owners > 1) overlaps += 1 + } + } + expect(gaps * 0.1 * 0.1).toBeLessThan(0.05) + expect(overlaps).toBe(0) + expect(countTopMaterialNonUpwardTriangles(roofMeshes[1]!.geometry)).toBe(0) + expect(closestMeshDistance(roofMeshes[1]!.geometry, roofMeshes[0]!.geometry)).toBeLessThan(1e-4) + expect(closestMeshDistance(roofMeshes[1]!.geometry, roofMeshes[2]!.geometry)).toBeLessThan(1e-4) + for (const mesh of [...roofMeshes, ...untrimmedMeshes]) mesh.geometry.dispose() + }) + + test('keeps the exported tight curved canopy roof skin facing upward', () => { + const walls = [ + WallNode.parse({ + id: 'wall_exported_left', + parentId: 'level_exported', + start: [-3, 6], + end: [-3, 0], + }), + WallNode.parse({ + id: 'wall_exported_curve', + parentId: 'level_exported', + start: [-3, 0], + end: [2, -3], + curveOffset: -2.91547594742265, + }), + WallNode.parse({ + id: 'wall_exported_right', + parentId: 'level_exported', + start: [2, -3], + end: [8, -3], + }), + ] + const leanTos = walls.map((wall, index) => ({ + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(wall, getWallCurveLength(wall) / 2, 'front')!, + wall, + ), + id: `leanto_exported_${index}`, + projection: index === 1 ? 2.7993049913193615 : 2.5, + pitch: index === 1 ? 8.949098978949332 : 10, + })) + const nodes = Object.fromEntries( + [...walls, ...leanTos].map((node) => [node.id, node]), + ) as Record + const segment = createLeanToAssembly(leanTos[1]!, undefined, nodes).segment + const geometry = generateRoofSegmentGeometry(segment) + + expect(segment.shedFootprintPieces).toHaveLength(38) + expect(countTopMaterialNonUpwardTriangles(geometry)).toBe(0) + expect(countEdgeMaterialVerticalTriangles(geometry)).toBeLessThan( + segment.shedFootprintPieces!.length * 4, + ) + + geometry.dispose() + }) + + test('keeps tangent straight sheds outside a semicircular host wall', () => { + const walls = [ + WallNode.parse({ + id: 'wall_semicircle_left', + parentId: 'level_semicircle', + start: [4, -7.5], + end: [4, 5], + }), + WallNode.parse({ + id: 'wall_semicircle_curve', + parentId: 'level_semicircle', + start: [4, 5], + end: [-3, 12], + curveOffset: -4.949747468305833, + }), + WallNode.parse({ + id: 'wall_semicircle_right', + parentId: 'level_semicircle', + start: [-3, 12], + end: [-12, 12], + }), + ] + const spans = [12.2, 15.238990719656629, 8.7] + const leanTos = walls.map((wall, index) => ({ + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(wall, getWallCurveLength(wall) / 2, 'front')!, + wall, + ), + id: `leanto_semicircle_${index}`, + span: spans[index]!, + projection: 2.5, + pitch: 10, + })) + const nodes = Object.fromEntries( + [...walls, ...leanTos].map((node) => [node.id, node]), + ) as Record + const assemblies = leanTos.map((leanTo) => createLeanToAssembly(leanTo, undefined, nodes)) + const meshes = assemblies.map( + (assembly, index) => + new THREE.Mesh( + generateRoofSegmentGeometry(assembly.segment).applyMatrix4( + segmentWorldMatrix(walls[index]!, leanTos[index]!, assembly.segment), + ), + ), + ) + const arc = getWallArcData(walls[1]!)! + const raycaster = new THREE.Raycaster() + raycaster.ray.direction.set(0, -1, 0) + let intrusions = 0 + for (let x = arc.center.x - arc.radius; x <= arc.center.x + arc.radius; x += 0.1) { + for (let z = arc.center.y - arc.radius; z <= arc.center.y + arc.radius; z += 0.1) { + if (Math.hypot(x - arc.center.x, z - arc.center.y) >= arc.radius - 0.05) continue + raycaster.ray.origin.set(x, 10, z) + for (const index of [0, 2]) { + if (raycaster.intersectObject(meshes[index]!, false).length > 0) intrusions++ + } + } + } + + expect(assemblies.map((assembly) => assembly.segment.shedFootprintPieces?.length)).toEqual([ + 102, 48, 77, + ]) + expect(intrusions).toBe(0) + + for (const mesh of meshes) mesh.geometry.dispose() + }) + test('resolves a reciprocal 60 degree corner with its true gutter mitre', () => { const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(60) @@ -923,8 +1336,8 @@ describe('lean-to corner joint', () => { assertTopGeometryFollowsRoofSlab(localGeometries[0]!, segmentA) assertTopGeometryFollowsRoofSlab(localGeometries[1]!, segmentB) - expect(countTopMaterialVerticalTriangles(localGeometries[0]!)).toBe(0) - expect(countTopMaterialVerticalTriangles(localGeometries[1]!)).toBe(0) + expect(countTopMaterialNonUpwardTriangles(localGeometries[0]!)).toBe(0) + expect(countTopMaterialNonUpwardTriangles(localGeometries[1]!)).toBe(0) const meshes = [ new THREE.Mesh( diff --git a/packages/nodes/src/lean-to-extension/system.test.ts b/packages/nodes/src/lean-to-extension/system.test.ts index b1af5b0404..2e6ce65875 100644 --- a/packages/nodes/src/lean-to-extension/system.test.ts +++ b/packages/nodes/src/lean-to-extension/system.test.ts @@ -1,17 +1,32 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, + type AnyNodeDefinition, type AnyNodeId, + BuildingNode, clearSceneHistory, createSceneApi, LeanToExtensionNode, LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, type SceneCommit, + SlabNode, subscribeSceneCommits, useScene, WallNode, } from '@pascal-app/core' -import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' +import { columnDefinition } from '../column' +import { + createLeanToAssembly, + leanToCornerPostIndex, + managedLeanToPostIndex, + managedLeanToPostSide, +} from './assembly' +import { resolveConicalLeanToPlacement } from './conical-host' +import { resolveLeanToSlabEdgePlacement } from './placement' import { initializeLeanToExtensionSync } from './system' type RafFn = (callback: (time: number) => void) => number @@ -27,6 +42,12 @@ type RafFn = (callback: (time: number) => void) => number let stopSync = () => {} describe('lean-to scene commit boundary', () => { + beforeAll(() => { + if (!nodeRegistry.has(columnDefinition.kind)) { + registerNode(columnDefinition as unknown as AnyNodeDefinition) + } + }) + beforeEach(() => { const level = LevelNode.parse({ id: 'level_lean_commit', level: 0 }) const wall = WallNode.parse({ @@ -107,6 +128,69 @@ describe('lean-to scene commit boundary', () => { expect(postAfterParentEdit.height).not.toBe(heightBeforeParentEdit) }) + test('tracks the conical host diameter and cylindrical wall height', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_conical_sync', level: 0 }) + const roof = RoofNode.parse({ + id: 'roof_conical_sync', + parentId: level.id, + children: ['rseg_conical_sync'], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_conical_sync', + parentId: roof.id, + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 3, + children: ['leanto_conical_sync'], + }) + const leanTo = resolveConicalLeanToPlacement(segment, { + id: 'leanto_conical_sync', + projection: 3, + })! + const assembly = createLeanToAssembly(leanTo) + const nodes = Object.fromEntries( + [ + { ...level, children: [roof.id] }, + roof, + segment, + 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)) + + useScene.getState().updateNode(segment.id as AnyNodeId, { + width: 10, + depth: 10, + wallHeight: 3.5, + }) + + const synced = useScene.getState().nodes[leanTo.id as AnyNodeId] + expect(synced?.type).toBe('lean-to-extension') + if (synced?.type !== 'lean-to-extension') return + expect(synced.projection).toBe(3) + expect(synced.span).toBeCloseTo(10 * Math.PI) + expect(synced.position).toEqual([0, 0, 5]) + expect(synced.spanArcCenterZ).toBe(-5) + expect(synced.spanArcRadius).toBe(5) + expect(synced.highEdgeHeight).toBe(3.5) + const posts = synced.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter((node) => node?.type === 'column') + expect(posts).toHaveLength(11) + }) + test('preserves the resolved free wall span across commit synchronization', () => { stopSync() const level = LevelNode.parse({ id: 'level_shared_wall', level: 0 }) @@ -266,6 +350,86 @@ describe('lean-to scene commit boundary', () => { expect(cornerPosts).toHaveLength(1) }) + test('synchronizes an edge-snapped straight run with open gutters and one joint pillar', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_linear_sync', level: 0 }) + const wall = WallNode.parse({ + id: 'wall_linear_sync', + parentId: level.id, + start: [0, 0], + end: [12, 0], + }) + const left = LeanToExtensionNode.parse({ + id: 'leanto_linear_sync_left', + parentId: wall.id, + position: [2, 0, 0.05], + span: 4, + }) + const right = LeanToExtensionNode.parse({ + id: 'leanto_linear_sync_right', + parentId: wall.id, + position: [6.3, 0, 0.05], + span: 4, + }) + const leftAssembly = createLeanToAssembly(left) + const rightAssembly = createLeanToAssembly(right) + const nodes = Object.fromEntries( + [ + { ...level, children: [wall.id] }, + { ...wall, children: [left.id, right.id] }, + leftAssembly.extension, + ...leftAssembly.children, + rightAssembly.extension, + ...rightAssembly.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + const synced = useScene.getState().nodes + const extensions = [left.id, right.id].map((id) => synced[id as AnyNodeId]) + expect(extensions.every((node) => node?.type === 'lean-to-extension')).toBe(true) + const posts = extensions.flatMap((node) => + node?.type === 'lean-to-extension' + ? node.children + .map((id) => synced[id as AnyNodeId]) + .filter((child) => child?.type === 'column') + : [], + ) + const jointPosts = posts.filter((post) => { + if (post?.type !== 'column') return false + const index = managedLeanToPostIndex(post) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }) + expect(jointPosts).toHaveLength(1) + + const gutters = extensions.map((node) => { + if (node?.type !== 'lean-to-extension') return undefined + const roof = node.children + .map((id) => synced[id as AnyNodeId]) + .find((child) => child?.type === 'roof') + if (roof?.type !== 'roof') return undefined + const segment = roof.children + .map((id) => synced[id as AnyNodeId]) + .find((child) => child?.type === 'roof-segment') + return segment?.type === 'roof-segment' + ? segment.children + .map((id) => synced[id as AnyNodeId]) + .find((child) => child?.type === 'gutter') + : undefined + }) + expect(gutters[0]?.type === 'gutter' && gutters[0].endCapRight).toBe(false) + expect(gutters[1]?.type === 'gutter' && gutters[1].endCapLeft).toBe(false) + }) + test('removes regular posts outside a synchronized internal L valley', () => { stopSync() const level = LevelNode.parse({ id: 'level_inner_post_sync', level: 0 }) @@ -330,4 +494,164 @@ describe('lean-to scene commit boundary', () => { expect(regularIndexesA).not.toContain(2) expect(regularIndexesB).not.toContain(0) }) + + test('tracks an upper slab edge while retaining one front row of posts', () => { + stopSync() + const building = BuildingNode.parse({ id: 'building_slab_host_sync' }) + const ground = LevelNode.parse({ + id: 'level_slab_host_ground', + parentId: building.id, + level: 0, + height: 3, + }) + const first = LevelNode.parse({ + id: 'level_slab_host_first', + parentId: building.id, + level: 1, + height: 3, + }) + const slab = SlabNode.parse({ + id: 'slab_host_sync', + parentId: first.id, + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + elevation: 0.05, + thickness: 0.2, + }) + const hostNodes = { + [building.id]: building, + [ground.id]: ground, + [first.id]: first, + [slab.id]: slab, + } as Record + const leanTo = resolveLeanToSlabEdgePlacement({ + activeLevelId: ground.id, + edgeIndex: 0, + edgeT: 0.5, + nodes: hostNodes, + slab, + })! + const assembly = createLeanToAssembly(leanTo, undefined, hostNodes) + const nodes = Object.fromEntries( + [ + { ...building, children: [ground.id, first.id] }, + { ...ground, children: [leanTo.id] }, + { ...first, children: [slab.id] }, + slab, + assembly.extension, + ...assembly.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [building.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + useScene.getState().updateNode(slab.id as AnyNodeId, { + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ], + elevation: 0.15, + }) + + const synced = useScene.getState().nodes[leanTo.id as AnyNodeId] + expect(synced?.type).toBe('lean-to-extension') + if (synced?.type !== 'lean-to-extension') return + expect(synced.position).toEqual([4, 0, 0]) + expect(synced.span).toBeCloseTo(7.9, 6) + expect(synced.highEdgeHeight).toBeCloseTo(2.95, 6) + const posts = synced.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter((node) => node?.type === 'column') + expect(posts).toHaveLength(4) + expect( + posts.every((post) => post?.type === 'column' && managedLeanToPostSide(post) === 'low'), + ).toBe(true) + }) + + test('keeps a deleted freestanding pillar omitted while the remaining pillars resize', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_omitted_post', level: 0 }) + const leanTo = LeanToExtensionNode.parse({ + id: 'leanto_omitted_post', + parentId: level.id, + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + connectionMode: 'manual', + autoSpan: false, + span: 4, + }) + const assembly = createLeanToAssembly(leanTo) + const nodes = Object.fromEntries( + [{ ...level, children: [leanTo.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)) + + const deletedPost = assembly.posts.find( + (post) => managedLeanToPostSide(post) === 'high' && managedLeanToPostIndex(post) === 2, + )! + const resizingPost = assembly.posts.find( + (post) => managedLeanToPostSide(post) === 'low' && managedLeanToPostIndex(post) === 2, + )! + const originalResizingX = resizingPost.position[0] + + useScene.getState().deleteNode(deletedPost.id as AnyNodeId) + + const afterDelete = useScene.getState().nodes[leanTo.id as AnyNodeId] + expect(afterDelete?.type).toBe('lean-to-extension') + if (afterDelete?.type !== 'lean-to-extension') return + expect(afterDelete.omittedPostSlots).toEqual([{ side: 'high', index: 2, layoutCount: 3 }]) + expect( + afterDelete.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .some( + (child) => + child?.type === 'column' && + managedLeanToPostSide(child) === 'high' && + managedLeanToPostIndex(child) === 2, + ), + ).toBe(false) + + useScene.getState().updateNode(leanTo.id as AnyNodeId, { span: 8 }) + + const afterResize = useScene.getState().nodes[leanTo.id as AnyNodeId] + expect(afterResize?.type).toBe('lean-to-extension') + if (afterResize?.type !== 'lean-to-extension') return + const posts = afterResize.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter((child): child is Extract => child?.type === 'column') + expect(posts).toHaveLength(7) + expect( + posts.some( + (post) => managedLeanToPostSide(post) === 'high' && managedLeanToPostIndex(post) === 3, + ), + ).toBe(false) + expect(posts.find((post) => post.id === resizingPost.id)?.position[0]).not.toBe( + originalResizingX, + ) + }) }) diff --git a/packages/nodes/src/lean-to-extension/system.tsx b/packages/nodes/src/lean-to-extension/system.tsx index 285727bd73..dc4f804739 100644 --- a/packages/nodes/src/lean-to-extension/system.tsx +++ b/packages/nodes/src/lean-to-extension/system.tsx @@ -35,17 +35,21 @@ import { resolveLeanToPostGutterSetback, resolveLeanToPostIndexes, } from './assembly' +import { resolveConicalLeanToPlacement } from './conical-host' import { LEAN_TO_CORNER_JOINTS_KEY, leanToCornerJointMetadata, resolveLeanToCornerJoints, } from './corner-joint' import { LEAN_TO_EXTENSION_GEOMETRY_REVISION, resolveLeanToSpanArc } from './layout' +import { reconcileLeanToSlabEdgePlacement } from './placement' import { resolveLeanToEndAbutments } from './placement-validation' +import { isLeanToPostOmitted } from './post-omissions' import { applyLeanToAvailableWallSpan, applyLeanToRoofAttachment, applyLeanToWallAutoSpan, + applyLeanToWallCornerSpan, clearLeanToRoofAttachment, resolveLeanToHostRoof, resolveLeanToRoofAttachment, @@ -173,6 +177,8 @@ function gutterNeedsLayoutUpdate( gutter.visible !== expected.visible || gutter.profile !== expected.profile || gutter.size !== expected.size || + gutter.endCapLeft !== expected.endCapLeft || + gutter.endCapRight !== expected.endCapRight || JSON.stringify(gutter.outlets) !== JSON.stringify(expected.outlets) || JSON.stringify(gutter.metadata) !== JSON.stringify(expected.metadata) ) @@ -204,9 +210,8 @@ function leanToGroundSignature( nodes: Record, ): number[] { const parent = leanTo.parentId ? nodes[leanTo.parentId as AnyNodeId] : undefined - if (parent?.type !== 'wall') return [] - const wall = parent as WallNode - const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) + const wall = parent?.type === 'wall' ? (parent as WallNode) : undefined + const cornerJoints = wall ? resolveLeanToCornerJoints(leanTo, wall, nodes) : {} const sides: LeanToPostSide[] = leanTo.highSideMode === 'independent-high-beam' ? ['low', 'high'] : ['low'] const values: number[] = [] @@ -215,8 +220,9 @@ function leanToGroundSignature( values.push(resolveLeanToPostBaseY(leanTo, wall, nodes, index, side)) } } - for (const joint of Object.values(cornerJoints)) { + for (const joint of wall ? Object.values(cornerJoints) : []) { if (!joint?.sharedPostOwner) continue + if (isLeanToPostOmitted(leanTo, 'low', leanToCornerPostIndex(joint.side))) continue const bent = bendLocalPoint(leanTo, joint.sharedPostPosition[0], joint.sharedPostPosition[2]) values.push( resolveLeanToPostBaseYAtLocalPosition(leanTo, wall, nodes, [ @@ -236,6 +242,7 @@ function extensionSignature( ): string { return JSON.stringify([ leanToGroundSignature(leanTo, nodes), + leanTo.hostKind, leanTo.span, leanTo.spanArcCenterZ, leanTo.spanArcRadius, @@ -263,6 +270,7 @@ function extensionSignature( leanTo.postLayoutMode, leanTo.postSpacing, leanTo.postInset, + leanTo.omittedPostSlots, leanTo.postBracing, leanTo.footingStyle, leanTo.highSideMode, @@ -315,6 +323,8 @@ function extensionSignature( function attachmentNeedsUpdate(current: LeanToExtensionNode, next: LeanToExtensionNode): boolean { return ( + current.hostKind !== next.hostKind || + current.highSideMode !== next.highSideMode || current.connectionMode !== next.connectionMode || current.hostRoofId !== next.hostRoofId || current.hostRoofSegmentId !== next.hostRoofSegmentId || @@ -330,6 +340,7 @@ function attachmentNeedsUpdate(current: LeanToExtensionNode, next: LeanToExtensi current.spanArcCenterZ !== next.spanArcCenterZ || current.spanArcRadius !== next.spanArcRadius || !sameTuple(current.position, next.position) || + !sameTuple(current.rotation, next.rotation) || current.roofThickness !== next.roofThickness || current.shingleThickness !== next.shingleThickness || JSON.stringify(current.metadata) !== JSON.stringify(next.metadata) @@ -348,11 +359,20 @@ function resolveEffectiveLeanTo( nodes: Record, ): LeanToExtensionNode { const parent = leanTo.parentId ? nodes[leanTo.parentId as AnyNodeId] : undefined + if (parent?.type === 'roof-segment' && leanTo.hostKind === 'conical-roof') { + return resolveConicalLeanToPlacement(parent, leanTo) ?? leanTo + } + if (leanTo.hostKind === 'slab-edge') { + return reconcileLeanToSlabEdgePlacement(leanTo, nodes) + } if (parent?.type !== 'wall') { - return leanTo.connectionMode === 'manual' ? leanTo : clearLeanToRoofAttachment(leanTo) + const detached = leanTo.connectionMode === 'manual' ? leanTo : clearLeanToRoofAttachment(leanTo) + return leanTo.hostKind === 'freestanding' + ? { ...detached, highSideMode: 'independent-high-beam' } + : detached } const wall = parent as WallNode - const wallSpanningLeanTo = applyLeanToWallAutoSpan(leanTo, wall) + const wallSpanningLeanTo = applyLeanToWallCornerSpan(applyLeanToWallAutoSpan(leanTo, wall), wall) const retained = leanTo.hostRoofSegmentId && leanTo.hostRoofEdge ? resolveLeanToRoofAttachment(wallSpanningLeanTo, wall, nodes, { @@ -369,17 +389,14 @@ function resolveEffectiveLeanTo( leanTo.connectionMode === 'manual' ? wallSpanningLeanTo : attachment - ? applyLeanToRoofAttachment(leanTo, attachment) + ? applyLeanToRoofAttachment(wallSpanningLeanTo, attachment) : clearLeanToRoofAttachment(wallSpanningLeanTo) - const withoutStaleJointEnds = leanTo.autoMiterCorners - ? { - ...resolved, - leftEndCondition: - resolved.leftEndCondition === 'joined' ? 'open' : resolved.leftEndCondition, - rightEndCondition: - resolved.rightEndCondition === 'joined' ? 'open' : resolved.rightEndCondition, - } - : resolved + const withoutStaleJointEnds = { + ...resolved, + leftEndCondition: resolved.leftEndCondition === 'joined' ? 'open' : resolved.leftEndCondition, + rightEndCondition: + resolved.rightEndCondition === 'joined' ? 'open' : resolved.rightEndCondition, + } const available = applyLeanToAvailableWallSpan( withoutStaleJointEnds, wall, @@ -460,6 +477,8 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { update.push({ id, data: { + hostKind: effectiveLeanTo.hostKind, + highSideMode: effectiveLeanTo.highSideMode, connectionMode: effectiveLeanTo.connectionMode, hostRoofId: effectiveLeanTo.hostRoofId, hostRoofSegmentId: effectiveLeanTo.hostRoofSegmentId, @@ -475,6 +494,7 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { spanArcCenterZ: effectiveLeanTo.spanArcCenterZ, spanArcRadius: effectiveLeanTo.spanArcRadius, position: effectiveLeanTo.position, + rotation: effectiveLeanTo.rotation, roofThickness: effectiveLeanTo.roofThickness, shingleThickness: effectiveLeanTo.shingleThickness, metadata: effectiveLeanTo.metadata, @@ -578,10 +598,13 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { for (const index of resolveLeanToPostIndexes(effectiveLeanTo, cornerJoints, side)) { const key = `${side}:${index}` desiredPostKeys.add(key) - const postBaseY = - parent?.type === 'wall' - ? resolveLeanToPostBaseY(effectiveLeanTo, parent, nodes, index, side) - : 0 + const postBaseY = resolveLeanToPostBaseY( + effectiveLeanTo, + parent?.type === 'wall' ? parent : undefined, + nodes, + index, + side, + ) const current = managedPosts.get(key) const gutterSetback = side === 'low' ? resolveLeanToPostGutterSetback(effectiveLeanTo, current) : 0 @@ -615,6 +638,7 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { for (const joint of Object.values(cornerJoints)) { if (!joint?.sharedPostOwner) continue const index = leanToCornerPostIndex(joint.side) + if (isLeanToPostOmitted(effectiveLeanTo, 'low', index)) continue const key = `low:${index}` desiredPostKeys.add(key) const bentCornerPost = bendLocalPoint( diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx index 3f31e3860e..72e71304da 100644 --- a/packages/nodes/src/lean-to-extension/tool.tsx +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -3,42 +3,57 @@ import { type AnyNode, type AnyNodeId, + type DoorEvent, emitter, + type GridEvent, getLevelElevations, getWallBaseElevationForNodes, + type RoofEvent, + type RoofSegmentEvent, + type SlabEvent, + sceneRegistry, type WallEvent, type WallNode, } from '@pascal-app/core' import { + isGridSnapActive, triggerSFX, useEditor, useInteractionScope, useRegistryToolContext, } from '@pascal-app/editor' import { useEffect, useState } from 'react' +import { Euler, Quaternion, Vector3 } from 'three' +import { stopPlacementCommitPropagation } from '../shared/floor-placement' import { createLeanToAssembly } from './assembly' +import { isConicalLeanToHostOccupied, resolveConicalLeanToSurfaceHit } from './conical-host' import { leanToExtensionGeometryKey } from './geometry' +import { leanToWallLocalPose, resolveLeanToWallSurfaceHit } from './layout' import { - leanToWallLocalPose, - resolveLeanToWallPlacement, - resolveLeanToWallSurfaceHit, -} from './layout' -import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' + findLeanToSlabEdgePlacement, + type LeanToPlanPlacementTarget, + nextLeanToPlacementRotation, + resolveLeanToCommitTarget, + resolveLeanToPlanPlacement, + resolveLeanToWallPlanTarget, +} from './placement' +import { isLeanToHostOnLevel } from './placement-scope' import LeanToExtensionPreview from './preview' -import { - applyLeanToAvailableWallSpan, - applyLeanToRoofAttachment, - applyLeanToWallAutoSpan, - clearLeanToRoofAttachment, - resolveLeanToHostRoof, - resolveLeanToRoofAttachment, -} from './roof-attachment' +import { resolveLeanToHostRoof } from './roof-attachment' import type { LeanToExtensionNode } from './schema' +import { resolveLeanToDoorWallTarget } from './wall-target' type PreviewPose = { node: LeanToExtensionNode position: [number, number, number] rotationY: number + valid: boolean +} + +type PlacementCommitTarget = { + node: LeanToExtensionNode + parentId: AnyNodeId + valid: boolean } const LeanToExtensionTool = () => { @@ -49,6 +64,11 @@ const LeanToExtensionTool = () => { useEffect(() => { if (!(activeLevelId && viewMode === '3d')) return useInteractionScope.getState().begin({ kind: 'drafting', tool: 'lean-to-extension' }) + let lastMeshEventTime = -1 + let freestandingRotationY = 0 + let lastFreestandingEvent: GridEvent | SlabEvent | null = null + let lastPreviewTarget: PlacementCommitTarget | null = null + let commitQueued = false const resolveBaseY = (wall: WallNode) => { const nodes = sceneApi.nodes() as Record @@ -56,80 +76,406 @@ const LeanToExtensionTool = () => { return levelY + getWallBaseElevationForNodes(wall, nodes) } - const updateTarget = (event: WallEvent) => { - const hit = resolveLeanToWallSurfaceHit(event.node, event.localPosition, event.normal) - if (!hit) { + const commitNode = (node: LeanToExtensionNode, parentId: AnyNodeId) => { + if (!sceneApi.createMany || commitQueued) return + commitQueued = true + queueMicrotask(() => { + commitQueued = false + }) + const nodes = sceneApi.nodes() as Record + const assembly = createLeanToAssembly(node, resolveLeanToHostRoof(node, nodes), nodes) + sceneApi.createMany([ + { node: assembly.extension, parentId }, + ...assembly.children.map((child) => ({ + node: child, + parentId: (child.parentId as AnyNodeId | null) ?? undefined, + })), + ]) + lastPreviewTarget = null + setPreview(null) + selectNode(assembly.extension.id as AnyNodeId) + triggerSFX('sfx:structure-build') + if (useEditor.getState().getContinuation('point') !== 'repeat') { + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + } + } + + const worldPreviewPose = ( + event: RoofEvent | RoofSegmentEvent, + node: LeanToExtensionNode, + localPosition: readonly [number, number, number], + extraRotationY = 0, + valid = true, + ): PreviewPose => { + const position = event.object.localToWorld(new Vector3(...localPosition)) + const rotationY = + new Euler().setFromQuaternion(event.object.getWorldQuaternion(new Quaternion()), 'YXZ').y + + extraRotationY + return { + node, + position: [position.x, position.y, position.z], + rotationY, + valid, + } + } + + const levelPreviewPose = (node: LeanToExtensionNode): PreviewPose => { + const levelObject = sceneRegistry.nodes.get(activeLevelId) + if (!levelObject) { + return { + node, + position: node.position, + rotationY: node.rotation[1], + valid: true, + } + } + const position = levelObject.localToWorld(new Vector3(...node.position)) + const rotationY = + new Euler().setFromQuaternion(levelObject.getWorldQuaternion(new Quaternion()), 'YXZ').y + + node.rotation[1] + return { node, position: [position.x, position.y, position.z], rotationY, valid: true } + } + + const updateFreeTarget = (event: GridEvent | SlabEvent) => { + const step = + !event.nativeEvent.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) + const nodes = sceneApi.nodes() as Record + const target = resolveLeanToPlanPlacement({ + activeLevelId, + freestandingPoint: [snap(event.localPosition[0]), snap(event.localPosition[2])], + freestandingRotationY, + nodes, + point: [event.localPosition[0], event.localPosition[2]], + }) + lastPreviewTarget = target.node.parentId + ? { + node: target.node, + parentId: target.node.parentId as AnyNodeId, + valid: target.valid, + } + : null + lastFreestandingEvent = target.node.hostKind === 'freestanding' ? event : null + if (target.wall) { + const pose = leanToWallLocalPose(target.wall, target.node, resolveBaseY(target.wall)) + setPreview((current) => ({ + node: + current && + leanToExtensionGeometryKey(current.node) === leanToExtensionGeometryKey(target.node) + ? current.node + : target.node, + ...pose, + valid: target.valid, + })) + } else { + setPreview({ ...levelPreviewPose(target.node), valid: target.valid }) + } + return target + } + + const updateSlabTarget = (event: SlabEvent): LeanToPlanPlacementTarget => { + const nodes = sceneApi.nodes() as Record + const node = findLeanToSlabEdgePlacement( + [event.localPosition[0], event.localPosition[2]], + nodes, + activeLevelId, + ) + if (!node || node.hostSlabId !== event.node.id) return updateFreeTarget(event) + lastFreestandingEvent = null + lastPreviewTarget = node.parentId + ? { node, parentId: node.parentId as AnyNodeId, valid: true } + : null + setPreview(levelPreviewPose(node)) + return { node, valid: true } + } + + const updateConicalSegmentTarget = (event: RoofSegmentEvent) => { + lastFreestandingEvent = null + const nodes = sceneApi.nodes() as Record + if (!isLeanToHostOnLevel(event.node, nodes, activeLevelId)) { + lastPreviewTarget = null setPreview(null) return null } - const wallPlacement = resolveLeanToWallPlacement(event.node, hit.localX, hit.side) - if (!wallPlacement) { + const node = resolveConicalLeanToSurfaceHit(event.node, event.localPosition, event.normal) + if (!node) { + lastPreviewTarget = null setPreview(null) return null } + const valid = !isConicalLeanToHostOccupied(event.node.id, nodes) + lastPreviewTarget = { node, parentId: event.node.id as AnyNodeId, valid } + setPreview(worldPreviewPose(event, node, node.position, 0, valid)) + return valid ? node : null + } + + const updateConicalRoofTarget = (event: RoofEvent) => { + lastFreestandingEvent = null const nodes = sceneApi.nodes() as Record - const attachment = resolveLeanToRoofAttachment(wallPlacement, event.node, nodes) - const autoSpannedNode = attachment - ? applyLeanToRoofAttachment(wallPlacement, attachment) - : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), event.node) - const attachedNode = applyLeanToAvailableWallSpan( - autoSpannedNode, - event.node, - nodes, - wallPlacement.position[0], - ) - const node = resolveLeanToEndAbutments(attachedNode, event.node, nodes) - if (leanToPlacementConflicts(node, event.node, nodes).length > 0) { + if ( + !isLeanToHostOnLevel(event.node, nodes, activeLevelId) || + event.object.name !== 'merged-roof' + ) { + lastPreviewTarget = null setPreview(null) return null } - const pose = leanToWallLocalPose(event.node, node, resolveBaseY(event.node)) + for (const childId of event.node.children) { + const segment = nodes[childId as AnyNodeId] + if (segment?.type !== 'roof-segment' || segment.roofType !== 'conical') continue + const cos = Math.cos(segment.rotation) + const sin = Math.sin(segment.rotation) + const dx = event.localPosition[0] - segment.position[0] + const dy = event.localPosition[1] - segment.position[1] + const dz = event.localPosition[2] - segment.position[2] + const localPosition: [number, number, number] = [ + dx * cos - dz * sin, + dy, + dx * sin + dz * cos, + ] + const normal = event.normal + ? ([ + event.normal[0] * cos - event.normal[2] * sin, + event.normal[1], + event.normal[0] * sin + event.normal[2] * cos, + ] as [number, number, number]) + : undefined + const node = resolveConicalLeanToSurfaceHit(segment, localPosition, normal) + if (!node) continue + const valid = !isConicalLeanToHostOccupied(segment.id, nodes) + lastPreviewTarget = { node, parentId: segment.id as AnyNodeId, valid } + const crownX = segment.position[0] + node.position[0] * cos + node.position[2] * sin + const crownZ = segment.position[2] - node.position[0] * sin + node.position[2] * cos + setPreview( + worldPreviewPose( + event, + node, + [crownX, segment.position[1] + node.position[1], crownZ], + segment.rotation, + valid, + ), + ) + return valid ? node : null + } + lastPreviewTarget = null + setPreview(null) + return null + } + + const updateTarget = (event: WallEvent) => { + lastFreestandingEvent = null + const nodes = sceneApi.nodes() as Record + if (!isLeanToHostOnLevel(event.node, nodes, activeLevelId)) { + lastPreviewTarget = null + setPreview(null) + return null + } + const hit = resolveLeanToWallSurfaceHit(event.node, event.localPosition, event.normal) + if (!hit) { + lastPreviewTarget = null + setPreview(null) + return null + } + const target = resolveLeanToWallPlanTarget(event.node, hit.localX, hit.side, nodes) + if (!target) { + lastPreviewTarget = null + setPreview(null) + return null + } + const pose = leanToWallLocalPose(event.node, target.node, resolveBaseY(event.node)) + lastPreviewTarget = { + node: target.node, + parentId: event.node.id as AnyNodeId, + valid: target.valid, + } setPreview((current) => ({ node: - current && leanToExtensionGeometryKey(current.node) === leanToExtensionGeometryKey(node) + current && + leanToExtensionGeometryKey(current.node) === leanToExtensionGeometryKey(target.node) ? current.node - : node, + : target.node, ...pose, + valid: target.valid, })) - return node + return target.valid ? target.node : null } const onWallMove = (event: WallEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp updateTarget(event) } const onWallLeave = () => { + lastFreestandingEvent = null + lastPreviewTarget = null setPreview(null) } const onWallClick = (event: WallEvent) => { - const node = updateTarget(event) - if (!node) return - event.stopPropagation() - const nodes = sceneApi.nodes() as Record - const assembly = createLeanToAssembly(node, resolveLeanToHostRoof(node, nodes), nodes) - sceneApi.createMany?.([ - { node: assembly.extension, parentId: event.node.id }, - ...assembly.children.map((child) => ({ - node: child, - parentId: (child.parentId as AnyNodeId | null) ?? undefined, - })), - ]) - selectNode(assembly.extension.id as AnyNodeId) - triggerSFX('sfx:structure-build') - if (useEditor.getState().getContinuation('point') !== 'repeat') { - useEditor.getState().setTool(null) - useEditor.getState().setMode('select') + lastMeshEventTime = event.nativeEvent.timeStamp + const visibleTarget = lastPreviewTarget + updateTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!target?.valid) return + stopPlacementCommitPropagation(event) + commitNode(target.node, target.parentId) + } + + const onDoorMove = (event: DoorEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + const wallId = event.node.wallId ?? event.node.parentId + const wall = wallId ? sceneApi.get(wallId as AnyNodeId) : undefined + const wallObject = wall ? sceneRegistry.nodes.get(wall.id) : undefined + if (!(wall?.type === 'wall' && wallObject)) { + lastPreviewTarget = null + setPreview(null) + return + } + updateTarget(resolveLeanToDoorWallTarget(event, wall, wallObject)) + } + + const onDoorClick = (event: DoorEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + const visibleTarget = lastPreviewTarget + const wallId = event.node.wallId ?? event.node.parentId + const wall = wallId ? sceneApi.get(wallId as AnyNodeId) : undefined + const wallObject = wall ? sceneRegistry.nodes.get(wall.id) : undefined + if (!(wall?.type === 'wall' && wallObject)) return + + const target = resolveLeanToDoorWallTarget(event, wall, wallObject) + updateTarget(target) + const commitTarget = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!commitTarget?.valid) return + stopPlacementCommitPropagation(event) + commitNode(commitTarget.node, commitTarget.parentId) + } + + const onDoorLeave = () => { + lastPreviewTarget = null + setPreview(null) + } + + const onRoofSegmentMove = (event: RoofSegmentEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + updateConicalSegmentTarget(event) + } + const onRoofSegmentClick = (event: RoofSegmentEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + const visibleTarget = lastPreviewTarget + updateConicalSegmentTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!target?.valid) return + stopPlacementCommitPropagation(event) + commitNode(target.node, target.parentId) + } + const onRoofMove = (event: RoofEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + updateConicalRoofTarget(event) + } + const onRoofClick = (event: RoofEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + const visibleTarget = lastPreviewTarget + updateConicalRoofTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!target?.valid) return + stopPlacementCommitPropagation(event) + commitNode(target.node, target.parentId) + } + const onSlabMove = (event: SlabEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + updateSlabTarget(event) + } + const onSlabClick = (event: SlabEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + const visibleTarget = lastPreviewTarget + updateSlabTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + stopPlacementCommitPropagation(event) + if (!target?.valid) return + commitNode(target.node, target.parentId) + } + const onGridMove = (event: GridEvent) => { + if (event.nativeEvent.timeStamp === lastMeshEventTime) return + updateFreeTarget(event) + } + const onGridClick = (event: GridEvent) => { + if (event.nativeEvent.timeStamp === lastMeshEventTime) return + const visibleTarget = lastPreviewTarget + updateFreeTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!target?.valid) return + commitNode(target.node, target.parentId) + } + + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return } + if (!lastFreestandingEvent) return + + const nextRotation = nextLeanToPlacementRotation( + freestandingRotationY, + event.key, + event.metaKey || event.ctrlKey, + ) + if (nextRotation === freestandingRotationY) return + + event.preventDefault() + freestandingRotationY = nextRotation + triggerSFX('sfx:item-rotate') + updateFreeTarget(lastFreestandingEvent) } emitter.on('wall:move', onWallMove) emitter.on('wall:enter', onWallMove) emitter.on('wall:leave', onWallLeave) emitter.on('wall:click', onWallClick) + emitter.on('door:move', onDoorMove) + emitter.on('door:enter', onDoorMove) + emitter.on('door:leave', onDoorLeave) + emitter.on('door:click', onDoorClick) + emitter.on('roof-segment:move', onRoofSegmentMove) + emitter.on('roof-segment:enter', onRoofSegmentMove) + emitter.on('roof-segment:leave', onWallLeave) + emitter.on('roof-segment:click', onRoofSegmentClick) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:enter', onRoofMove) + emitter.on('roof:leave', onWallLeave) + emitter.on('roof:click', onRoofClick) + emitter.on('slab:move', onSlabMove) + emitter.on('slab:enter', onSlabMove) + emitter.on('slab:leave', onWallLeave) + emitter.on('slab:click', onSlabClick) + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + window.addEventListener('keydown', onKeyDown) return () => { emitter.off('wall:move', onWallMove) emitter.off('wall:enter', onWallMove) emitter.off('wall:leave', onWallLeave) emitter.off('wall:click', onWallClick) + emitter.off('door:move', onDoorMove) + emitter.off('door:enter', onDoorMove) + emitter.off('door:leave', onDoorLeave) + emitter.off('door:click', onDoorClick) + emitter.off('roof-segment:move', onRoofSegmentMove) + emitter.off('roof-segment:enter', onRoofSegmentMove) + emitter.off('roof-segment:leave', onWallLeave) + emitter.off('roof-segment:click', onRoofSegmentClick) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:enter', onRoofMove) + emitter.off('roof:leave', onWallLeave) + emitter.off('roof:click', onRoofClick) + emitter.off('slab:move', onSlabMove) + emitter.off('slab:enter', onSlabMove) + emitter.off('slab:leave', onWallLeave) + emitter.off('slab:click', onSlabClick) + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + window.removeEventListener('keydown', onKeyDown) setPreview(null) useInteractionScope .getState() @@ -140,7 +486,7 @@ const LeanToExtensionTool = () => { if (!preview || viewMode !== '3d') return null return ( - + ) } diff --git a/packages/nodes/src/lean-to-extension/wall-target.test.ts b/packages/nodes/src/lean-to-extension/wall-target.test.ts new file mode 100644 index 0000000000..6069d0c0e6 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/wall-target.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test' +import { DoorNode, WallNode } from '@pascal-app/core' +import { Object3D, Vector3 } from 'three' +import { resolveLeanToDoorWallTarget } from './wall-target' + +describe('lean-to wall targets', () => { + test('converts a hosted door hit into the wall local frame', () => { + const wall = WallNode.parse({ id: 'wall_door_target', start: [0, 0], end: [6, 0] }) + const door = DoorNode.parse({ id: 'door_target', wallId: wall.id }) + const wallObject = new Object3D() + wallObject.position.set(10, 2, -4) + wallObject.rotation.y = 0.35 + const doorObject = new Object3D() + doorObject.position.set(2.25, 1.1, 0.08) + wallObject.add(doorObject) + wallObject.updateWorldMatrix(true, true) + + const worldPoint = doorObject.localToWorld(new Vector3(0, 0, 0)) + const target = resolveLeanToDoorWallTarget( + { + node: door, + position: [worldPoint.x, worldPoint.y, worldPoint.z], + localPosition: [0, 0, 0], + normal: [0, 0, 1], + object: doorObject, + stopPropagation: () => {}, + nativeEvent: {} as never, + }, + wall, + wallObject, + ) + + expect(target.node.id).toBe(wall.id) + expect(target.localPosition[0]).toBeCloseTo(2.25) + expect(target.localPosition[1]).toBeCloseTo(1.1) + expect(target.localPosition[2]).toBeCloseTo(0.08) + expect(target.normal?.[0]).toBeCloseTo(0) + expect(target.normal?.[1]).toBeCloseTo(0) + expect(target.normal?.[2]).toBeCloseTo(1) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/wall-target.ts b/packages/nodes/src/lean-to-extension/wall-target.ts new file mode 100644 index 0000000000..32a56fd8b1 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/wall-target.ts @@ -0,0 +1,38 @@ +import type { DoorEvent, WallEvent, WallNode } from '@pascal-app/core' +import type { Object3D } from 'three' +import { Vector3 } from 'three' + +/** + * Re-attributes a hosted door hit to its wall while preserving the hit in + * world space. Door face normals are local to the intersected door object; + * converting through that object keeps rotated doors and hosted cutout meshes + * aligned with the wall's local placement frame. + */ +export function resolveLeanToDoorWallTarget( + event: DoorEvent, + wall: WallNode, + wallObject: Object3D, +): WallEvent { + wallObject.updateWorldMatrix(true, false) + event.object.updateWorldMatrix(true, false) + + const worldPoint = new Vector3(...event.position) + const localPoint = wallObject.worldToLocal(worldPoint.clone()) + const normal = event.normal + ? (() => { + const objectOrigin = event.object.localToWorld(new Vector3()) + const objectNormalPoint = event.object.localToWorld(new Vector3(...event.normal!)) + const worldNormal = objectNormalPoint.sub(objectOrigin).normalize() + const localNormalPoint = wallObject.worldToLocal(worldPoint.clone().add(worldNormal)) + return localNormalPoint.sub(localPoint).normalize() + })() + : new Vector3(0, 0, localPoint.z >= 0 ? 1 : -1) + + return { + ...event, + node: wall, + localPosition: [localPoint.x, localPoint.y, localPoint.z], + normal: [normal.x, normal.y, normal.z], + object: wallObject, + } +} diff --git a/packages/nodes/src/roof-segment/definition.test.ts b/packages/nodes/src/roof-segment/definition.test.ts index 42812c0575..4d93e6046a 100644 --- a/packages/nodes/src/roof-segment/definition.test.ts +++ b/packages/nodes/src/roof-segment/definition.test.ts @@ -3,6 +3,7 @@ import { getActiveRoofHeight, type HandleDescriptor, type LinearResizeHandle, + type RadialResizeHandle, type RoofSegmentNode, } from '@pascal-app/core' import { roofSegmentDefinition } from './definition' @@ -64,6 +65,28 @@ function pitchHandle(): LinearResizeHandle { } describe('roof-segment resize handles', () => { + test('uses one center-anchored radius handle for a conical segment', () => { + const node = segment({ roofType: 'conical', width: 6, depth: 6 }) + const conicalHandles = handles(node) + const radiusHandles = conicalHandles.filter( + (handle): handle is RadialResizeHandle => handle.kind === 'radial-resize', + ) + const sideHandles = conicalHandles.filter( + (handle) => handle.kind === 'linear-resize' && (handle.axis === 'x' || handle.axis === 'z'), + ) + const radiusHandle = radiusHandles[0] + + expect(radiusHandles).toHaveLength(1) + expect(sideHandles).toHaveLength(0) + expect(radiusHandle?.currentValue(node)).toBe(3) + expect({ ...node, ...radiusHandle?.apply(node, 4, undefined as never) }).toMatchObject({ + width: 8, + depth: 8, + position: [10, 0, 20], + }) + expect(conicalHandles.some((handle) => handle.kind === 'arc-resize')).toBe(false) + }) + test('place shed side handles at roof level', () => { const node = segment() const roofHeight = getActiveRoofHeight(node) @@ -97,29 +120,15 @@ describe('roof-segment resize handles', () => { expect(backPatch).toMatchObject({ depth: 8, position: [10, 0, 19] }) }) - test('hides the pitch handle for managed lean-to roof segments', () => { + test('hides the pitch handle for parent-managed roof segments', () => { const handle = pitchHandle() - const managed = segment({ - metadata: { - managedByLeanTo: 'lean_to_test', - leanToRole: 'roof-segment', - }, - }) + const managed = segment({ managedByParent: true }) expect(handle.visible?.(segment(), undefined as never)).not.toBe(false) expect(handle.visible?.(managed, undefined as never)).toBe(false) }) - test('hides all direct handles for managed lean-to roof segments', () => { - expect( - handles( - segment({ - metadata: { - managedByLeanTo: 'lean_to_test', - leanToRole: 'roof-segment', - }, - }), - ), - ).toEqual([]) + test('hides all direct handles for parent-managed roof segments', () => { + expect(handles(segment({ managedByParent: true }))).toEqual([]) }) }) diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 1ded893143..e1c7eb2524 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -37,13 +37,6 @@ function getPeakHeight(n: RoofSegmentNodeType): number { return n.wallHeight + getActiveRoofHeight(n) } -function isManagedLeanToRoofSegment(n: RoofSegmentNodeType): boolean { - const metadata = n.metadata - if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return false - const record = metadata as Record - return record.managedByLeanTo !== undefined && record.leanToRole === 'roof-segment' -} - function getSideResizeHandleY(n: RoofSegmentNodeType, localZ: number): number { if (n.roofType !== 'shed') return Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2 @@ -87,6 +80,7 @@ function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor { + return { + kind: 'radial-resize', + axis: 'x', + min: MIN_ROOF_DIM / 2, + currentValue: (n) => n.width / 2, + apply: (_initial, radius) => ({ width: radius * 2, depth: radius * 2 }), + placement: { + position: (n) => [n.width / 2 + SIDE_HANDLE_OFFSET, getSideResizeHandleY(n, 0), 0], + }, + decoration: { + kind: 'ring', + radius: (n) => n.width / 2, + y: (n) => getSideResizeHandleY(n, 0), + }, + } +} + // Wall-height tracker — dashed vertical leader from the floor up to a // draggable cube at the wall top, centred on the footprint. Replaces // the old -X-side chevron so the wall-top control reads as "the wall is @@ -210,7 +230,7 @@ function roofSegmentPitchHandle(): HandleDescriptor { min: (n) => n.wallHeight, gridSnap: true, currentValue: (n) => getPeakHeight(n), - visible: (n) => !isManagedLeanToRoofSegment(n), + visible: (n) => !n.managedByParent, apply: (initial, newPeakHeight) => { const roofHeight = Math.max(0, newPeakHeight - initial.wallHeight) const pitch = getPitchFromActiveRoofHeight({ @@ -273,10 +293,17 @@ const roofSegmentHandles: HandleDescriptor[] = [ roofSegmentRotateHandle(), ] +const conicalRoofSegmentHandles: HandleDescriptor[] = [ + conicalRoofSegmentRadiusHandle(), + roofSegmentWallHeightHandle(), + roofSegmentPitchHandle(), +] + function resolveRoofSegmentHandles( node: RoofSegmentNodeType, ): HandleDescriptor[] { - return isManagedLeanToRoofSegment(node) ? [] : roofSegmentHandles + if (node.managedByParent) return [] + return node.roofType === 'conical' ? conicalRoofSegmentHandles : roofSegmentHandles } /** @@ -287,7 +314,7 @@ function resolveRoofSegmentHandles( */ export const roofSegmentDefinition: NodeDefinition = { kind: 'roof-segment', - schemaVersion: 1, + schemaVersion: 2, schema: RoofSegmentNode, category: 'structure', surfaceRole: 'roof', diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.test.ts b/packages/nodes/src/roof-segment/floorplan-affordances.test.ts new file mode 100644 index 0000000000..a79283a88a --- /dev/null +++ b/packages/nodes/src/roof-segment/floorplan-affordances.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + type AnyNodeId, + RoofNode, + RoofSegmentNode, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { roofSegmentResizeAffordance } from './floorplan-affordances' + +globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} + +const modifiers = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false } + +afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) +}) + +describe('roof-segment floor-plan resize affordance', () => { + test('resizes a conical segment by radius without moving its center', () => { + const roof = RoofNode.parse({ id: 'roof_conical_resize', children: ['rseg_conical_resize'] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_conical_resize', + parentId: roof.id, + position: [10, 0, 20], + roofType: 'conical', + width: 6, + depth: 6, + }) + const nodes = { [roof.id]: roof, [segment.id]: segment } + useScene.setState({ nodes } as never) + const session = roofSegmentResizeAffordance.start({ + node: segment, + payload: { mode: 'radial' }, + nodes: useScene.getState().nodes, + initialPlanPoint: [13, 20], + gridSnapStep: 0.1, + }) + + session.apply({ planPoint: [14, 20], modifiers }) + + expect(useScene.getState().nodes[segment.id]).toBe(segment) + expect(useLiveNodeOverrides.getState().get(segment.id as AnyNodeId)).toMatchObject({ + width: 8, + depth: 8, + }) + session.commit?.() + expect(useScene.getState().nodes[segment.id]).toMatchObject({ + width: 8, + depth: 8, + position: [10, 0, 20], + }) + }) +}) diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.ts b/packages/nodes/src/roof-segment/floorplan-affordances.ts index 70fa8ecd0d..8de6640b57 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -8,13 +8,13 @@ import { useLiveNodeOverrides, useScene, } from '@pascal-app/core' -import { getSegmentGridStep, isAngleSnapActive } from '@pascal-app/editor' +import { getSegmentGridStep, isAngleSnapActive, isGridSnapActive } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' import { rotateAffordanceDelta } from '../shared/rotate-affordance' const MIN_ROOF_DIM = 1 -type RoofSegmentResizePayload = { axis: 'x' | 'z'; side: 1 | -1 } +type RoofSegmentResizePayload = { mode: 'radial' } | { axis: 'x' | 'z'; side: 1 | -1 } // Resolve world-space center + effective rotation of a roof segment by // composing the parent roof's position + rotation with the segment's @@ -61,14 +61,44 @@ function resolveSegmentFrame( */ export const roofSegmentResizeAffordance: FloorplanAffordance = { start({ node, payload, nodes, initialPlanPoint }) { - const { axis, side } = payload as RoofSegmentResizePayload + const resize = payload as RoofSegmentResizePayload const segmentId = node.id as AnyNodeId + const { cx, cz } = resolveSegmentFrame(node, nodes) + if ('mode' in resize) { + const initialRadius = node.width / 2 + const initialPointerRadius = Math.hypot(initialPlanPoint[0] - cx, initialPlanPoint[1] - cz) + let lastRadius = initialRadius + + return { + affectedIds: [segmentId], + apply({ planPoint }) { + const pointerRadius = Math.hypot(planPoint[0] - cx, planPoint[1] - cz) + lastRadius = Math.max( + MIN_ROOF_DIM / 2, + initialRadius + pointerRadius - initialPointerRadius, + ) + const diameter = lastRadius * 2 + useLiveNodeOverrides.getState().set(segmentId, { width: diameter, depth: diameter }) + useScene.getState().markDirty(segmentId) + }, + canCommit() { + return true + }, + commit() { + useLiveNodeOverrides.getState().clear(segmentId) + const diameter = lastRadius * 2 + useScene.getState().updateNode(segmentId, { width: diameter, depth: diameter }) + }, + } + } + + const { axis, side } = resize const initialValue = axis === 'x' ? node.width : node.depth const initialPosition = node.position const segmentRotation = node.rotation ?? 0 const armX = axis === 'x' ? Math.cos(segmentRotation) : Math.sin(segmentRotation) const armZ = axis === 'x' ? -Math.sin(segmentRotation) : Math.cos(segmentRotation) - const { cx, cz, effRot } = resolveSegmentFrame(node, nodes) + const { effRot } = resolveSegmentFrame(node, nodes) const cosEff = Math.cos(effRot) const sinEff = Math.sin(effRot) // Project (planPoint - center) onto the segment's local X or Z axis @@ -91,7 +121,7 @@ export const roofSegmentResizeAffordance: FloorplanAffordance = // Mode-aware grid step (0 outside grid mode, so `lines` / `off` resize // freely — the "smooth" behaviour that used to need a held Shift). The // reshaping scope opened by the dispatcher resolves the `polygon` set. - const step = getSegmentGridStep() + const step = isGridSnapActive() ? getSegmentGridStep() : 0 const snappedValue = step > 0 ? snapScalar(rawValue, step) : rawValue const newValue = Math.max(MIN_ROOF_DIM, snappedValue) const centerOffset = (side * (newValue - initialValue)) / 2 @@ -101,12 +131,13 @@ export const roofSegmentResizeAffordance: FloorplanAffordance = initialPosition[2] + centerOffset * armZ, ] lastValue = newValue - useLiveNodeOverrides - .getState() - .set( - segmentId, - axis === 'x' ? { width: newValue, position } : { depth: newValue, position }, - ) + const dimensions = + node.roofType === 'conical' + ? { width: newValue, depth: newValue } + : axis === 'x' + ? { width: newValue } + : { depth: newValue } + useLiveNodeOverrides.getState().set(segmentId, { ...dimensions, position }) useScene.getState().markDirty(segmentId) }, canCommit() { @@ -120,12 +151,13 @@ export const roofSegmentResizeAffordance: FloorplanAffordance = initialPosition[1], initialPosition[2] + centerOffset * armZ, ] - useScene - .getState() - .updateNode( - segmentId, - axis === 'x' ? { width: lastValue, position } : { depth: lastValue, position }, - ) + const dimensions = + node.roofType === 'conical' + ? { width: lastValue, depth: lastValue } + : axis === 'x' + ? { width: lastValue } + : { depth: lastValue } + useScene.getState().updateNode(segmentId, { ...dimensions, position }) }, } }, @@ -204,7 +236,7 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget = ({ no // Mode-aware: `getSegmentGridStep()` is 0 outside grid mode (so `lines` / // `off` move freely), and the `moving` scope resolves the `polygon` set // via the kind's `snapProfile` — no held-Shift bypass. - const step = getSegmentGridStep() + const step = isGridSnapActive() ? getSegmentGridStep() : 0 const snap = (value: number) => snapScalar(value, step) const worldPoint = resolveCursor(planPoint, { snap }) const dx = worldPoint[0] - roofPosX diff --git a/packages/nodes/src/roof-segment/floorplan.test.ts b/packages/nodes/src/roof-segment/floorplan.test.ts index 6b0ab302ea..7187d96102 100644 --- a/packages/nodes/src/roof-segment/floorplan.test.ts +++ b/packages/nodes/src/roof-segment/floorplan.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from 'bun:test' -import type { RoofSegmentNode } from '@pascal-app/core' -import { getRoofSegmentPlanLinework } from './floorplan' +import { + type FloorplanGeometry, + type GeometryContext, + RoofNode, + type RoofSegmentNode, +} from '@pascal-app/core' +import { buildRoofSegmentFloorplan, getRoofSegmentPlanLinework } from './floorplan' function dutchSegment(overrides: Partial = {}): RoofSegmentNode { return { @@ -34,6 +39,34 @@ function dutchSegment(overrides: Partial = {}): RoofSegmentNode } describe('getRoofSegmentPlanLinework', () => { + test('renders conical selection and hit chrome as a circle', () => { + const roof = RoofNode.parse({ id: 'roof_test', children: ['rseg_test'] }) + const node = dutchSegment({ + parentId: roof.id, + roofType: 'conical', + width: 6, + depth: 6, + }) + const geometry = buildRoofSegmentFloorplan(node, { + parent: roof, + viewState: { selected: true }, + } as GeometryContext) as Extract + + expect(geometry.kind).toBe('group') + expect(geometry.children.filter((child) => child.kind === 'circle')).toHaveLength(2) + expect(geometry.children.some((child) => child.kind === 'polygon')).toBe(false) + expect(geometry.children.some((child) => child.kind === 'rotate-arrow')).toBe(false) + const resizeArrows = geometry.children.filter((child) => child.kind === 'move-arrow') + expect(resizeArrows).toHaveLength(1) + expect(resizeArrows[0]).toMatchObject({ payload: { mode: 'radial' } }) + expect(getRoofSegmentPlanLinework(node)).toEqual({ + ridges: [], + hips: [], + breaks: [], + slope: null, + }) + }) + test('draws a dutch width-axis upper ridge plus waist linework', () => { const linework = getRoofSegmentPlanLinework(dutchSegment()) diff --git a/packages/nodes/src/roof-segment/floorplan.ts b/packages/nodes/src/roof-segment/floorplan.ts index 43a36b634e..21fd393062 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -73,19 +73,33 @@ export function buildRoofSegmentFloorplan( const baseInk = '#111111' const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseInk + const footprint: FloorplanGeometry = + node.roofType === 'conical' + ? { + kind: 'circle', + cx, + cy: cz, + r: halfWidth, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + } + : { + kind: 'polygon', + points, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + } const children: FloorplanGeometry[] = [ // Invisible hit-target — full footprint, transparent fill, captures // clicks across the entire roof rectangle (so the user doesn't need // to pixel-hunt the outline strokes). - { - kind: 'polygon', - points, - fill: stroke, - fillOpacity: 0, - stroke: 'none', - strokeWidth: 0, - pointerEvents: 'all', - }, + footprint, ] // The segment's own rectangle outline + fill render ONLY while it's @@ -95,15 +109,28 @@ export function buildRoofSegmentFloorplan( // (`buildRoofFloorplan`), so overlapping segments read as one combined // shape instead of stacked rectangles. Ridges/hips below always draw. if (showSelectedChrome) { - children.push({ - kind: 'polygon', - points, - fill: '#fed7aa', - fillOpacity: 0.55, - stroke, - strokeWidth: 0.035, - strokeLinejoin: 'miter', - }) + children.push( + node.roofType === 'conical' + ? { + kind: 'circle', + cx, + cy: cz, + r: halfWidth, + fill: '#fed7aa', + fillOpacity: 0.55, + stroke, + strokeWidth: 0.035, + } + : { + kind: 'polygon', + points, + fill: '#fed7aa', + fillOpacity: 0.55, + stroke, + strokeWidth: 0.035, + strokeLinejoin: 'miter', + }, + ) } // NOTE: the ridge / hip / break / slope linework is NOT drawn here — the @@ -112,9 +139,8 @@ export function buildRoofSegmentFloorplan( // The shape math lives in `getRoofSegmentPlanLinework` (exported for the // roof builder to consume). - // Selection chrome — orange move-handle dot at the centre, four - // perpendicular side resize-arrows (width on X, depth on Z), and a - // rotate-arrow at the +X/+Z corner. Sister to the 3D handles in + // Selection chrome — orange move-handle dot at the centre, footprint + // resize arrows, and a rotate-arrow at the +X/+Z corner. Sister to the 3D handles in // `definition.ts`. Resize/rotate route through the matching // `floorplanAffordances`; the dot drives body-move via // `def.floorplanMoveTarget`. @@ -135,41 +161,55 @@ export function buildRoofSegmentFloorplan( lx * cos - ly * sin, lx * sin + ly * cos, ] - const sides: Array<{ - local: [number, number] - localAngle: number - axis: 'x' | 'z' - side: 1 | -1 - }> = [ - { local: [halfW + sideArrowOffset, 0], localAngle: 0, axis: 'x', side: 1 }, - { local: [-(halfW + sideArrowOffset), 0], localAngle: Math.PI, axis: 'x', side: -1 }, - { local: [0, halfD + sideArrowOffset], localAngle: Math.PI / 2, axis: 'z', side: 1 }, - { local: [0, -(halfD + sideArrowOffset)], localAngle: -Math.PI / 2, axis: 'z', side: -1 }, - ] - for (const s of sides) { - const [ox, oz] = rotateLocal(s.local[0], s.local[1]) - const [tx, tz] = rotateLocal(Math.cos(s.localAngle), Math.sin(s.localAngle)) + if (node.roofType === 'conical') { + const [ox, oz] = rotateLocal(halfW + sideArrowOffset, 0) + const [tx, tz] = rotateLocal(1, 0) children.push({ kind: 'move-arrow', point: [cx + ox, cz + oz], angle: Math.atan2(tz, tx), affordance: 'roof-segment-resize', - payload: { axis: s.axis, side: s.side }, + payload: { mode: 'radial' }, }) + } else { + const sides: Array<{ + local: [number, number] + localAngle: number + axis: 'x' | 'z' + side: 1 | -1 + }> = [ + { local: [halfW + sideArrowOffset, 0], localAngle: 0, axis: 'x', side: 1 }, + { local: [-(halfW + sideArrowOffset), 0], localAngle: Math.PI, axis: 'x', side: -1 }, + { local: [0, halfD + sideArrowOffset], localAngle: Math.PI / 2, axis: 'z', side: 1 }, + { local: [0, -(halfD + sideArrowOffset)], localAngle: -Math.PI / 2, axis: 'z', side: -1 }, + ] + for (const side of sides) { + const [ox, oz] = rotateLocal(side.local[0], side.local[1]) + const [tx, tz] = rotateLocal(Math.cos(side.localAngle), Math.sin(side.localAngle)) + children.push({ + kind: 'move-arrow', + point: [cx + ox, cz + oz], + angle: Math.atan2(tz, tx), + affordance: 'roof-segment-resize', + payload: { axis: side.axis, side: side.side }, + }) + } } // Rotate-arrow at the +X / +Z corner. Local angle π/4 puts the // curved arrow's bow at the diagonal corner so it reads as a // rotation gizmo around the segment centre. - const [cornerX, cornerZ] = rotateLocal(halfW + rotateCornerOffset, halfD + rotateCornerOffset) - const [radialX, radialZ] = rotateLocal(1, 1) - children.push({ - kind: 'rotate-arrow', - point: [cx + cornerX, cz + cornerZ], - angle: Math.atan2(radialZ, radialX), - affordance: 'roof-segment-rotate', - pivot: [cx, cz], - }) + if (node.roofType !== 'conical') { + const [cornerX, cornerZ] = rotateLocal(halfW + rotateCornerOffset, halfD + rotateCornerOffset) + const [radialX, radialZ] = rotateLocal(1, 1) + children.push({ + kind: 'rotate-arrow', + point: [cx + cornerX, cz + cornerZ], + angle: Math.atan2(radialZ, radialX), + affordance: 'roof-segment-rotate', + pivot: [cx, cz], + }) + } } return { kind: 'group', children } @@ -233,6 +273,7 @@ export function getRoofSegmentPlanLinework(node: RoofSegmentNode): { } switch (node.roofType) { + case 'conical': case 'flat': break case 'gable': diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index 0f5ee3c4e9..edba6a81b8 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -43,6 +43,10 @@ const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [ { label: 'Mansard', value: 'mansard' }, ] +const ROOF_TYPE_OPTIONS_3: { label: string; value: RoofType }[] = [ + { label: 'Conical', value: 'conical' }, +] + // Carpenter / roofer convention: rise over a 12" run, converted to degrees. // atan(3/12) ≈ 14.04°, atan(6/12) ≈ 26.57°, atan(9/12) ≈ 36.87°, atan(12/12) = 45°. const PITCH_PRESETS: { label: string; deg: number }[] = [ @@ -127,23 +131,43 @@ export default function RoofSegmentPanel() { const handleRoofTypeChange = useCallback( (roofType: RoofType) => { if (isManagedLeanToRoofSegment(node?.metadata)) return + if (roofType === 'conical' && node) { + const scene = useScene.getState() + const defaultVentIds = (node.children ?? []).filter((childId) => + isDefaultRidgeVentNode(scene.nodes[childId as AnyNodeId], node.id), + ) as AnyNodeId[] + if (defaultVentIds.length > 0) scene.deleteNodes(defaultVentIds) + } // Switching to Dutch resets the shape parameters to their defaults so the // gablet is well-formed regardless of the leftover values from the // previous roof type. handleUpdate( - roofType === 'dutch' + roofType === 'conical' ? { roofType, - dutchHipWidthRatio: ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, - dutchHipHeightRatio: ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, - dutchWaistLengthRatio: ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, - dutchGabletRake: ROOF_SHAPE_DEFAULTS.dutchGabletRake, - dutchTopRakeThickness: ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness, + depth: node?.width ?? 8, + rotation: 0, + trim: EMPTY_TRIM, + metadata: { + ...metadataRecord(node?.metadata), + autoGutter: false, + autoRidgeVent: false, + showTrimPlanes: false, + }, } - : { roofType }, + : roofType === 'dutch' + ? { + roofType, + dutchHipWidthRatio: ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + dutchHipHeightRatio: ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + dutchWaistLengthRatio: ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + dutchGabletRake: ROOF_SHAPE_DEFAULTS.dutchGabletRake, + dutchTopRakeThickness: ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness, + } + : { roofType }, ) }, - [handleUpdate, node?.metadata], + [handleUpdate, node], ) const handleClose = useCallback(() => { @@ -310,67 +334,92 @@ export default function RoofSegmentPanel() { value={node.roofType} disabled={managedLeanToRoofSegment} /> + handleRoofTypeChange(v)} + options={ROOF_TYPE_OPTIONS_3} + value={node.roofType} + disabled={managedLeanToRoofSegment} + /> - - - - ) : ( - - ) - } - label={showTrimPlanes ? 'Done editing' : 'Edit footprint'} - onClick={() => (showTrimPlanes ? handleBack() : handleTrimEditing(true))} - /> - } - label="Reset" - onClick={handleResetTrim} - /> - - {node.roofType !== 'shed' && node.roofType !== 'flat' && ( + {node.roofType !== 'conical' && ( + + + + ) : ( + + ) + } + label={showTrimPlanes ? 'Done editing' : 'Edit footprint'} + onClick={() => (showTrimPlanes ? handleBack() : handleTrimEditing(true))} + /> + } + label="Reset" + onClick={handleResetTrim} + /> + + {node.roofType !== 'shed' && node.roofType !== 'flat' && ( + + )} + + )} + + {node.roofType !== 'conical' && ( + - )} - - - - - + + )} - handleUpdate({ width: v })} - precision={2} - step={0.5} - unit="m" - value={Math.round(node.width * 100) / 100} - /> - handleUpdate({ depth: v })} - precision={2} - step={0.5} - unit="m" - value={Math.round(node.depth * 100) / 100} - /> + {node.roofType === 'conical' ? ( + handleUpdate({ width: v, depth: v })} + precision={2} + step={0.5} + unit="m" + value={Math.round(node.width * 100) / 100} + /> + ) : ( + <> + handleUpdate({ width: v })} + precision={2} + step={0.5} + unit="m" + value={Math.round(node.width * 100) / 100} + /> + handleUpdate({ depth: v })} + precision={2} + step={0.5} + unit="m" + value={Math.round(node.depth * 100) / 100} + /> + + )} @@ -609,34 +658,38 @@ export default function RoofSegmentPanel() { unit="m" value={Math.round(node.position[2] * 100) / 100} /> - { - handleUpdate({ rotation: (degrees * Math.PI) / 180 }) - }} - precision={0} - step={1} - unit="°" - value={Math.round((node.rotation * 180) / Math.PI)} - /> -
- { - triggerSFX('sfx:item-rotate') - handleUpdate({ rotation: node.rotation - Math.PI / 4 }) - }} - /> - { - triggerSFX('sfx:item-rotate') - handleUpdate({ rotation: node.rotation + Math.PI / 4 }) - }} - /> -
+ {node.roofType !== 'conical' && ( + <> + { + handleUpdate({ rotation: (degrees * Math.PI) / 180 }) + }} + precision={0} + step={1} + unit="°" + value={Math.round((node.rotation * 180) / Math.PI)} + /> +
+ { + triggerSFX('sfx:item-rotate') + handleUpdate({ rotation: node.rotation - Math.PI / 4 }) + }} + /> + { + triggerSFX('sfx:item-rotate') + handleUpdate({ rotation: node.rotation + Math.PI / 4 }) + }} + /> +
+ + )}
diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index ab2130b433..d8f4ee1f55 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -108,7 +108,7 @@ export const roofDefinition: NodeDefinition = { // Drafted as a 2-corner footprint (axis-aligned bbox), not a directional // edge → no angle-lock mode (grid / lines / off only). snapDraftDirectional: false, - schemaVersion: 1, + schemaVersion: 2, schema: RoofNode, category: 'structure', surfaceRole: 'roof', diff --git a/packages/nodes/src/roof/floorplan.test.ts b/packages/nodes/src/roof/floorplan.test.ts index dc53153e1a..3cebb07735 100644 --- a/packages/nodes/src/roof/floorplan.test.ts +++ b/packages/nodes/src/roof/floorplan.test.ts @@ -82,4 +82,55 @@ describe('buildRoofFloorplan roof intersections', () => { expect(Math.min(...hostOutline.map(([x]) => x))).toBeCloseTo(-5, 6) expect(Math.max(...hostOutline.map(([x]) => x))).toBeCloseTo(5, 6) }) + + test('keeps a mounted conical roof visible above its host in plan view', () => { + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + children: ['rseg_host'], + }) + const conicalRoof = RoofNode.parse({ + id: 'roof_conical', + type: 'roof', + children: ['rseg_conical'], + support: { + kind: 'roof', + roofSegmentId: 'rseg_host', + localPosition: [0, 0], + curbHeight: 0.5, + }, + }) + const hostSegment = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'gable', + width: 10, + depth: 8, + }) + const conicalSegment = RoofSegmentNode.parse({ + id: 'rseg_conical', + type: 'roof-segment', + parentId: conicalRoof.id, + roofType: 'conical', + width: 3, + depth: 3, + }) + const nodes = { + [hostRoof.id]: hostRoof, + [conicalRoof.id]: conicalRoof, + [hostSegment.id]: hostSegment, + [conicalSegment.id]: conicalSegment, + } + + const geometry = buildRoofFloorplan( + conicalRoof, + buildContext(conicalRoof, [conicalSegment], [hostRoof], nodes), + ) + const outline = outlinePoints(geometry) + + expect(geometry).not.toBeNull() + expect(Math.min(...outline.map(([x]) => x))).toBeCloseTo(-1.5, 6) + expect(Math.max(...outline.map(([x]) => x))).toBeCloseTo(1.5, 6) + }) }) diff --git a/packages/nodes/src/roof/floorplan.ts b/packages/nodes/src/roof/floorplan.ts index 02a6eec796..4a9fd29296 100644 --- a/packages/nodes/src/roof/floorplan.ts +++ b/packages/nodes/src/roof/floorplan.ts @@ -4,7 +4,7 @@ import { type GeometryContext, type RoofNode, type RoofSegmentNode, - roofOverlapEntryOwns, + roofPlanOverlapEntryOwns, subtractPolygonsFromPolygon, unionPolygons, } from '@pascal-app/core' @@ -27,6 +27,26 @@ type PlanEntry = { plan: SegPlan } +function overlapEntry(entry: PlanEntry, ctx: GeometryContext) { + const supportSegment = + entry.roof.support?.kind === 'roof' + ? ctx.resolve(entry.roof.support.roofSegmentId) + : undefined + return { + roofId: String(entry.roof.id), + segmentId: String(entry.segment.id), + supportRoofId: + supportSegment?.type === 'roof-segment' && supportSegment.parentId + ? String(supportSegment.parentId) + : undefined, + supportRoofSegmentId: + entry.roof.support?.kind === 'roof' ? String(entry.roof.support.roofSegmentId) : undefined, + roofType: entry.segment.roofType, + width: entry.segment.width, + depth: entry.segment.depth, + } +} + /** A segment's footprint + ridge/hip/break/slope linework, in world plan coords. */ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { const cosRoof = Math.cos(-roof.rotation) @@ -47,8 +67,15 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { tp(s[0][0], s[0][1]), tp(s[1][0], s[1][1]), ] + const footprint = + seg.roofType === 'conical' + ? Array.from({ length: 48 }, (_, index) => { + const angle = (-index / 48) * Math.PI * 2 + return tp(Math.cos(angle) * hw, Math.sin(angle) * hw) + }) + : [tp(-hw, -hd), tp(hw, -hd), tp(hw, hd), tp(-hw, hd)] return { - footprint: [tp(-hw, -hd), tp(hw, -hd), tp(hw, hd), tp(-hw, hd)], + footprint, ridges: lw.ridges.map(mapSeg), hips: lw.hips.map(mapSeg), breaks: lw.breaks.map(mapSeg), @@ -153,20 +180,7 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp .filter((candidate) => { if (candidate.segment.id === entry.segment.id) return false if (candidate.segment.roofType === 'shed') return false - return roofOverlapEntryOwns( - { - roofId: String(candidate.roof.id), - segmentId: String(candidate.segment.id), - width: candidate.segment.width, - depth: candidate.segment.depth, - }, - { - roofId: String(entry.roof.id), - segmentId: String(entry.segment.id), - width: entry.segment.width, - depth: entry.segment.depth, - }, - ) + return roofPlanOverlapEntryOwns(overlapEntry(candidate, ctx), overlapEntry(entry, ctx)) }) .map((candidate) => candidate.plan.footprint) return { diff --git a/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts b/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts new file mode 100644 index 0000000000..4ff4d57814 --- /dev/null +++ b/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type DormerEvent, + DormerNode, + type WindowEvent, + WindowNode, +} from '@pascal-app/core' +import { Object3D } from 'three' +import { + dormerEventFromHostedWindow, + getDormerWindowWorldNormal, + getDormerWindowWorldYaw, + resolveDormerWindowTarget, + shouldWriteDormerWindowPreviewHost, +} from './dormer-wall-opening-placement' + +function event( + node: DormerNode, + localPosition: [number, number, number], + normal?: [number, number, number], +): DormerEvent { + return { + node, + localPosition, + normal, + } as DormerEvent +} + +describe('dormerEventFromHostedWindow', () => { + test('forwards a hosted back-window hit into the dormer coordinate frame', () => { + const dormer = DormerNode.parse({ id: 'dormer_test' }) + const window = WindowNode.parse({ + dormerFace: 'back', + dormerId: dormer.id, + id: 'window_test', + parentId: dormer.id, + }) + const object = new Object3D() + object.position.set(2, 3, 4) + object.updateMatrixWorld(true) + const stopPropagation = () => {} + const windowEvent = { + faceIndex: 7, + nativeEvent: { timeStamp: 10 }, + node: window, + position: [3, 5, 7], + stopPropagation, + } as unknown as WindowEvent + + const dormerEvent = dormerEventFromHostedWindow(windowEvent, dormer, object) + + expect(dormerEvent.node).toBe(dormer) + expect(dormerEvent.localPosition).toEqual([1, 2, 3]) + expect(dormerEvent.normal).toEqual([0, 0, -1]) + expect(dormerEvent.faceIndex).toBe(7) + expect(dormerEvent.stopPropagation).toBe(stopPropagation) + }) +}) + +describe('resolveDormerWindowTarget', () => { + test('clamps a front-face window in dormer-local coordinates', () => { + const dormer = DormerNode.parse({ + depth: 2, + height: 1, + id: 'dormer_test', + wallSkirtHeight: 2, + width: 3, + }) + + const target = resolveDormerWindowTarget({ + event: event(dormer, [1.8, 0.8, 1], [0, 0, 1]), + height: 1, + nodes: {}, + width: 1, + }) + + expect(target?.face).toBe('front') + expect(target?.position).toEqual([1, 0.5, 0]) + expect(target?.valid).toBe(true) + }) + + test('rejects overlap with another window on the same face', () => { + const child = WindowNode.parse({ + dormerFace: 'front', + dormerId: 'dormer_test', + height: 1, + id: 'window_existing', + parentId: 'dormer_test', + position: [0, 0, 0], + width: 1, + }) + const dormer = DormerNode.parse({ + children: [child.id], + depth: 2, + height: 1, + id: 'dormer_test', + wallSkirtHeight: 2, + width: 3, + }) + + const target = resolveDormerWindowTarget({ + event: event(dormer, [0, 0, 1], [0, 0, 1]), + height: 1, + nodes: { [child.id]: child } as Record, + width: 1, + }) + + expect(target?.valid).toBe(false) + }) + + test('falls back to the nearest dormer face when the ray has no normal', () => { + const dormer = DormerNode.parse({ + depth: 2, + height: 1, + id: 'dormer_test', + wallSkirtHeight: 2, + width: 3, + }) + + const target = resolveDormerWindowTarget({ + event: event(dormer, [0, 0.2, -1]), + height: 0.5, + nodes: {}, + width: 0.5, + }) + + expect(target?.face).toBe('back') + expect(target?.valid).toBe(true) + }) + + test.each([ + ['front', [0, 0, 1] as const, [0, 0, 1] as const], + ['back', [0, 0, -1] as const, [0, 0, -1] as const], + ['right', [1.5, 0, 0] as const, [1, 0, 0] as const], + ['left', [-1.5, 0, 0] as const, [-1, 0, 0] as const], + ])('targets the %s dormer face while dragging', (face, localPosition, normal) => { + const dormer = DormerNode.parse({ + depth: 2, + height: 1, + id: 'dormer_test', + wallSkirtHeight: 2, + width: 3, + }) + + const target = resolveDormerWindowTarget({ + event: event(dormer, [...localPosition], [...normal]), + height: 0.5, + nodes: {}, + width: 0.5, + }) + + expect(target?.face).toBe(face) + expect(target?.valid).toBe(true) + }) + + test('preserves the rendered horizontal direction on a side face', () => { + const dormer = DormerNode.parse({ + depth: 2, + height: 1, + id: 'dormer_test', + wallSkirtHeight: 2, + width: 3, + }) + + const target = resolveDormerWindowTarget({ + event: event(dormer, [1.5, 0, -0.5], [1, 0, 0]), + height: 0.5, + nodes: {}, + width: 0.5, + }) + + expect(target?.face).toBe('right') + expect(target?.position[0]).toBeCloseTo(0.5) + }) + + test('uses the live grid step and keeps raw coordinates when grid snapping is off', () => { + const dormer = DormerNode.parse({ + depth: 2, + height: 1, + id: 'dormer_test', + wallSkirtHeight: 2, + width: 3, + }) + const resolve = (snap: (value: number) => number) => + resolveDormerWindowTarget({ + event: event(dormer, [0.36, -0.64, 1], [0, 0, 1]), + height: 0.5, + nodes: {}, + snap, + width: 0.5, + }) + + expect(resolve((value) => Math.round(value / 0.5) * 0.5)?.position).toEqual([0.5, -0.5, 0]) + expect(resolve((value) => Math.round(value / 0.25) * 0.25)?.position).toEqual([0.25, -0.75, 0]) + expect(resolve((value) => value)?.position).toEqual([0.36, -0.64, 0]) + }) + + test('clamps a side-face window to the sloped shed wall above the eave', () => { + const dormer = DormerNode.parse({ + depth: 4, + height: 1, + id: 'dormer_test', + roofHeight: 2, + roofType: 'shed', + shedHighSide: 'back', + wallSkirtHeight: 2, + width: 4, + }) + + const target = resolveDormerWindowTarget({ + event: event(dormer, [2, 3, -1], [1, 0, 0]), + height: 1, + nodes: {}, + width: 1, + }) + + expect(target?.face).toBe('right') + expect(target?.position).toEqual([1, 1.75, 0]) + }) +}) + +describe('getDormerWindowWorldYaw', () => { + test('orients the drag preview to side faces and the dormer world rotation', () => { + const dormer = DormerNode.parse({ id: 'dormer_test' }) + const object = new Object3D() + object.rotation.y = 0.4 + object.updateMatrixWorld(true) + const dormerEvent = { ...event(dormer, [0, 0, 0]), object } + + expect( + getDormerWindowWorldYaw(dormerEvent, { + dormer, + face: 'right', + position: [0, 0, 0], + valid: true, + }), + ).toBeCloseTo(0.4 + Math.PI / 2) + }) +}) + +describe('getDormerWindowWorldNormal', () => { + test('returns the world-space normal of a rotated dormer face', () => { + const dormer = DormerNode.parse({ id: 'dormer_test' }) + const object = new Object3D() + object.rotation.y = 0.4 + object.updateMatrixWorld(true) + const dormerEvent = { ...event(dormer, [0, 0, 0]), object } + + const normal = getDormerWindowWorldNormal(dormerEvent, { + dormer, + face: 'right', + position: [0, 0, 0], + valid: true, + }) + + expect(normal.x).toBeCloseTo(Math.sin(0.4 + Math.PI / 2)) + expect(normal.y).toBeCloseTo(0) + expect(normal.z).toBeCloseTo(Math.cos(0.4 + Math.PI / 2)) + }) +}) + +describe('shouldWriteDormerWindowPreviewHost', () => { + test('writes only once across repeated samples on one dormer face', () => { + const dormer = DormerNode.parse({ id: 'dormer_test' }) + let window = WindowNode.parse({ + dormerFace: 'front', + dormerId: dormer.id, + id: 'window_test', + parentId: dormer.id, + }) + let writes = 0 + + for (let index = 0; index < 100; index += 1) { + const target = { + dormer, + face: 'front' as const, + position: [index / 100, -0.5, 0] as [number, number, number], + valid: true, + } + if (!shouldWriteDormerWindowPreviewHost(window, target)) continue + writes += 1 + window = WindowNode.parse({ + ...window, + dormerFace: target.face, + dormerId: target.dormer.id, + parentId: target.dormer.id, + position: target.position, + visible: false, + }) + } + + expect(writes).toBe(1) + }) + + test('writes once when the preview enters a dormer face', () => { + const dormer = DormerNode.parse({ id: 'dormer_test' }) + const window = WindowNode.parse({ + id: 'window_test', + parentId: 'wall_test', + wallId: 'wall_test', + }) + const target = { + dormer, + face: 'front' as const, + position: [0, -0.5, 0] as [number, number, number], + valid: true, + } + + expect(shouldWriteDormerWindowPreviewHost(window, target)).toBe(true) + }) + + test('writes when the preview crosses onto another dormer face', () => { + const dormer = DormerNode.parse({ id: 'dormer_test' }) + const window = WindowNode.parse({ + dormerFace: 'front', + dormerId: dormer.id, + id: 'window_test', + parentId: dormer.id, + visible: false, + }) + const target = { + dormer, + face: 'right' as const, + position: [0, -0.5, 0] as [number, number, number], + valid: true, + } + + expect(shouldWriteDormerWindowPreviewHost(window, target)).toBe(true) + }) +}) diff --git a/packages/nodes/src/shared/dormer-wall-opening-placement.ts b/packages/nodes/src/shared/dormer-wall-opening-placement.ts new file mode 100644 index 0000000000..bdd2ffab70 --- /dev/null +++ b/packages/nodes/src/shared/dormer-wall-opening-placement.ts @@ -0,0 +1,164 @@ +import { + type AnyNode, + type DormerEvent, + type DormerNode, + dormerPointToWallFace, + getDormerWallFaceFrame, + getDormerWallOpeningVerticalBounds, + type WindowEvent, + type WindowNode, +} from '@pascal-app/core' +import { type Object3D, Vector3 } from 'three' + +export type DormerWindowTarget = { + dormer: DormerNode + face: NonNullable + position: [number, number, number] + valid: boolean +} + +const dormerFaceNormal = new Vector3() + +export function dormerEventFromHostedWindow( + event: WindowEvent, + dormer: DormerNode, + object: Object3D, +): DormerEvent { + object.updateWorldMatrix(true, false) + const localPoint = object.worldToLocal(new Vector3(...event.position)) + const face = event.node.dormerFace ?? 'front' + const normal: [number, number, number] = + face === 'front' + ? [0, 0, 1] + : face === 'back' + ? [0, 0, -1] + : face === 'right' + ? [1, 0, 0] + : [-1, 0, 0] + + return { + node: dormer, + normal, + object, + position: event.position, + localPosition: [localPoint.x, localPoint.y, localPoint.z], + faceIndex: event.faceIndex, + nativeEvent: event.nativeEvent, + stopPropagation: event.stopPropagation, + } +} + +export function getDormerWindowWorldYaw(event: DormerEvent, target: DormerWindowTarget): number { + const normal = getDormerWindowWorldNormal(event, target) + return Math.atan2(normal.x, normal.z) +} + +export function getDormerWindowWorldNormal( + event: DormerEvent, + target: DormerWindowTarget, + out = dormerFaceNormal, +): Vector3 { + const frame = getDormerWallFaceFrame(event.node, target.face) + event.object.updateWorldMatrix(true, false) + return out + .set(Math.sin(frame.yaw), 0, Math.cos(frame.yaw)) + .transformDirection(event.object.matrixWorld) +} + +export function shouldWriteDormerWindowPreviewHost( + node: WindowNode, + target: DormerWindowTarget, +): boolean { + return ( + node.parentId !== target.dormer.id || + node.dormerId !== target.dormer.id || + node.dormerFace !== target.face || + node.wallId !== undefined || + node.roofSegmentId !== undefined || + node.roofFace !== undefined || + node.visible !== false + ) +} + +function faceFromNormal(normal: DormerEvent['normal']): DormerWindowTarget['face'] | null { + if (!normal) return null + const [x, , z] = normal + if (Math.abs(z) >= Math.abs(x)) return z >= 0 ? 'front' : 'back' + return x >= 0 ? 'right' : 'left' +} + +function faceFromPoint( + dormer: DormerNode, + point: [number, number, number], +): DormerWindowTarget['face'] { + const distances = [ + { face: 'front' as const, distance: Math.abs(point[2] - dormer.depth / 2) }, + { face: 'back' as const, distance: Math.abs(point[2] + dormer.depth / 2) }, + { face: 'right' as const, distance: Math.abs(point[0] - dormer.width / 2) }, + { face: 'left' as const, distance: Math.abs(point[0] + dormer.width / 2) }, + ] + return distances.reduce((closest, current) => + current.distance < closest.distance ? current : closest, + ).face +} + +function hasWindowOverlap( + dormer: DormerNode, + nodes: Readonly>, + face: DormerWindowTarget['face'], + position: [number, number, number], + width: number, + height: number, + ignoreId?: string, +): boolean { + const left = position[0] - width / 2 + const right = position[0] + width / 2 + const bottom = position[1] - height / 2 + const top = position[1] + height / 2 + + return (dormer.children ?? []).some((childId) => { + if (childId === ignoreId) return false + const child = nodes[childId] + if (child?.type !== 'window' || child.dormerFace !== face) return false + return ( + Math.abs(child.position[0] - position[0]) < (child.width + width) / 2 && + Math.abs(child.position[1] - position[1]) < (child.height + height) / 2 && + child.position[0] + child.width / 2 > left && + child.position[0] - child.width / 2 < right && + child.position[1] + child.height / 2 > bottom && + child.position[1] - child.height / 2 < top + ) + }) +} + +export function resolveDormerWindowTarget(args: { + event: DormerEvent + width: number + height: number + nodes: Readonly> + ignoreId?: string + snap?: (value: number) => number +}): DormerWindowTarget | null { + const { event, width, height, nodes, ignoreId, snap = (value) => value } = args + const face = faceFromNormal(event.normal) ?? faceFromPoint(event.node, event.localPosition) + + const point = dormerPointToWallFace(event.node, face, event.localPosition) + const frame = getDormerWallFaceFrame(event.node, face) + const clampedX = Math.max( + -frame.width / 2 + width / 2, + Math.min(frame.width / 2 - width / 2, snap(point[0])), + ) + const vertical = getDormerWallOpeningVerticalBounds(event.node, face, clampedX, width) + const minY = vertical.min + height / 2 + const maxY = vertical.max - height / 2 + if (maxY < minY) return null + const clampedY = Math.max(minY, Math.min(maxY, snap(point[1]))) + const position: [number, number, number] = [clampedX, clampedY, 0] + + return { + dormer: event.node, + face, + position, + valid: !hasWindowOverlap(event.node, nodes, face, position, width, height, ignoreId), + } +} diff --git a/packages/nodes/src/shared/ridge-snap.ts b/packages/nodes/src/shared/ridge-snap.ts index bb2f569709..d5c5c8cdf7 100644 --- a/packages/nodes/src/shared/ridge-snap.ts +++ b/packages/nodes/src/shared/ridge-snap.ts @@ -43,7 +43,7 @@ export function resolveRidgeSnap( cursorLocalZ: number, ): RidgeSnap | null { const roofType = segment.roofType ?? 'gable' - if (roofType === 'flat') return null + if (roofType === 'flat' || roofType === 'conical') return null const lines = roofType === 'shed' diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts index aa6369563c..78343f696a 100644 --- a/packages/nodes/src/shared/roof-surface.ts +++ b/packages/nodes/src/shared/roof-surface.ts @@ -136,7 +136,12 @@ function buildRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { let shinTopD = shinBotD let transZ = 0 - if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') { + if ( + roofType === 'hip' || + roofType === 'mansard' || + roofType === 'dutch' || + roofType === 'conical' + ) { shinTopW += 2 * stSin shinTopD += 2 * stSin } else if (roofType === 'gable' || roofType === 'gambrel') { @@ -434,6 +439,12 @@ export function getAnalyticalNormal( return buildSlopeNormal(0, 1, primaryTan, out) } + if (roofType === 'conical') { + const radius = Math.hypot(lx, lz) + if (radius <= 1e-6) return out.set(0, 1, 0) + return buildSlopeNormal(lx / radius, lz / radius, primaryTan, out) + } + // 4-sided slopes: the dominant axis chooses which face the point sits // on. Hip is uniform across all four faces. Mansard has a steep outer // band (primaryTan) and a shallow top inside the waist. Dutch has hip diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index 0c6dc53e65..39fe520206 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -3,9 +3,12 @@ import { type AnyNodeId, collectLevelWallSegments, getScaledDimensions, + getWallArcData, + getWallCurveFrameAt, + getWallCurveLength, type ItemNode, + isCurvedWall, nearestWallSegment, - useScene, WALL_SNAP_DISTANCE_M, type WallNode, } from '@pascal-app/core' @@ -109,6 +112,135 @@ export function findClosestWallInPlan( } } +type CurvedWallPlanHit = { + distance: number + localX: number + perpDistance: number + dirX: number + dirY: number + wallLength: number +} + +export type WallPlanAttachment = Omit & { + distance: number +} + +function closestCurvedWallInPlan( + wall: WallNode, + planPoint: readonly [number, number], + maxDistance: number, +): CurvedWallPlanHit | null { + const arc = getWallArcData(wall) + const wallLength = getWallCurveLength(wall) + if (!arc || wallLength <= 1e-6) return null + + const pointAngle = Math.atan2(planPoint[1] - arc.center.y, planPoint[0] - arc.center.x) + let directedAngle = (pointAngle - arc.startAngle) * arc.direction + while (directedAngle < 0) directedAngle += Math.PI * 2 + + const candidates = [0, 1] + const arcAngle = Math.abs(arc.delta) + if (directedAngle <= arcAngle) candidates.push(directedAngle / arcAngle) + + let best: { distance: number; t: number } | null = null + for (const t of candidates) { + const frame = getWallCurveFrameAt(wall, t) + const distance = Math.hypot(planPoint[0] - frame.point.x, planPoint[1] - frame.point.y) + if (!best || distance < best.distance) best = { distance, t } + } + if (!best || best.distance > maxDistance) return null + + const frame = getWallCurveFrameAt(wall, best.t) + const perpDistance = + (planPoint[0] - frame.point.x) * frame.normal.x + + (planPoint[1] - frame.point.y) * frame.normal.y + return { + distance: best.distance, + localX: wallLength * best.t, + perpDistance, + dirX: frame.tangent.x, + dirY: frame.tangent.y, + wallLength, + } +} + +/** Resolve a plan point against one wall, including its curved centerline. */ +export function resolveWallAttachmentAtPlanPoint( + wall: WallNode, + planPoint: readonly [number, number], + maxDistance = WALL_SNAP_DISTANCE_M, +): WallPlanAttachment | null { + if (!isCurvedWall(wall)) { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(dx, dz) + if (wallLength <= 1e-6) return null + const dirX = dx / wallLength + const dirY = dz / wallLength + const px = planPoint[0] - wall.start[0] + const pz = planPoint[1] - wall.start[1] + const localX = Math.max(0, Math.min(wallLength, px * dirX + pz * dirY)) + const perpDistance = px * -dirY + pz * dirX + const closestX = wall.start[0] + dirX * localX + const closestZ = wall.start[1] + dirY * localX + const distance = Math.hypot(planPoint[0] - closestX, planPoint[1] - closestZ) + if (distance > maxDistance) return null + const side: 'front' | 'back' = perpDistance >= 0 ? 'front' : 'back' + return { + distance, + localX, + perpDistance, + side, + dirX, + dirY, + wallLength, + itemRotation: side === 'front' ? 0 : Math.PI, + } + } + + const curvedHit = closestCurvedWallInPlan(wall, planPoint, maxDistance) + if (!curvedHit || curvedHit.distance > maxDistance) return null + const side: 'front' | 'back' = curvedHit.perpDistance >= 0 ? 'front' : 'back' + return { + distance: curvedHit.distance, + localX: curvedHit.localX, + perpDistance: curvedHit.perpDistance, + side, + dirX: curvedHit.dirX, + dirY: curvedHit.dirY, + wallLength: curvedHit.wallLength, + itemRotation: side === 'front' ? 0 : Math.PI, + } +} + +/** + * Return the closest wall attachment target in plan space, including curved + * walls. This is deliberately separate from `findClosestWallInPlan`: doors, + * windows, and wall-mounted items still use the straight-wall-only opening + * query, while lean-to canopies have analytic curved-wall support. + */ +export function findClosestWallAttachmentInPlan( + planPoint: readonly [number, number], + nodes: Record, + parentLevelId: AnyNodeId | null, + excludeWallId?: AnyNodeId, +): WallHit | null { + if (!parentLevelId) return null + const level = nodes[parentLevelId] + const childIds = (level as unknown as { children?: AnyNodeId[] })?.children + if (!Array.isArray(childIds)) return null + + let best: { hit: WallHit; distance: number } | null = null + for (const childId of childIds) { + const node = nodes[childId] + if (node?.type !== 'wall' || node.id === excludeWallId) continue + const attachment = resolveWallAttachmentAtPlanPoint(node, planPoint) + if (!attachment || (best && attachment.distance >= best.distance)) continue + best = { hit: { wall: node, ...attachment }, distance: attachment.distance } + } + return best?.hit ?? null +} + /** Figma-style along-wall alignment threshold (meters) — parity with the * XZ placement / move threshold. */ const ALONG_WALL_ALIGN_THRESHOLD_M = 0.08 @@ -202,13 +334,13 @@ export function snapLocalXToNeighbors(args: { */ export function hasWallChildOverlap( wallId: string, + nodes: Readonly>, clampedX: number, clampedY: number, width: number, height: number, ignoreId?: string, ): boolean { - const nodes = useScene.getState().nodes const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined if (!wallNode) return true const halfW = width / 2 @@ -274,7 +406,7 @@ export type OpeningPlacement = { /** * Resolve the placement state from the raw collision result and whether the - * user is force-placing (Shift). Force-place lifts the collision block, so the + * user is force-placing (held Alt). Force-place lifts the collision block, so the * opening becomes placeable AND the tint goes green — the preview and the * commit gate stay in lockstep because both read this one result. */ diff --git a/packages/nodes/src/window/definition.test.ts b/packages/nodes/src/window/definition.test.ts new file mode 100644 index 0000000000..ead72bac43 --- /dev/null +++ b/packages/nodes/src/window/definition.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + DormerNode, + type HandleDescriptor, + LevelNode, + RoofNode, + RoofSegmentNode, + type SceneApi, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { resolveWindowHandlePortalTarget, windowDefinition } from './definition' + +const windowHandles = windowDefinition.handles as HandleDescriptor[] + +function sceneWith(...nodes: AnyNode[]): SceneApi { + const byId = Object.fromEntries(nodes.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + return { + get: (id: AnyNodeId) => byId[id], + nodes: () => byId, + } as SceneApi +} + +function handleMax(index: number, window: WindowNode, scene: SceneApi): number { + const handle = windowHandles[index] + if (handle?.kind !== 'linear-resize') throw new Error(`Expected linear window handle ${index}`) + return typeof handle.max === 'function' ? handle.max(window, scene) : (handle.max ?? Infinity) +} + +function resizeToMax(index: number, window: WindowNode, scene: SceneApi): Partial { + const handle = windowHandles[index] + if (handle?.kind !== 'linear-resize') throw new Error(`Expected linear window handle ${index}`) + return handle.apply(window, handleMax(index, window, scene), scene) +} + +describe('window handle presentation', () => { + test('does not register the legacy move arrow', () => { + const handles = windowDefinition.handles as HandleDescriptor[] + + expect(handles.some((handle) => 'shape' in handle && handle.shape === 'move-cross')).toBe(false) + }) + + test('opts every resize arrow into live grid snapping', () => { + expect( + windowHandles.every((handle) => handle.kind !== 'linear-resize' || handle.gridSnap === true), + ).toBe(true) + }) + + test('portals dormer-window handles outside the roof-segment container', () => { + const roof = RoofNode.parse({ id: 'roof_test' }) + const segment = RoofSegmentNode.parse({ id: 'rseg_test', parentId: roof.id }) + const dormer = DormerNode.parse({ id: 'dormer_test', parentId: segment.id }) + const window = WindowNode.parse({ + dormerId: dormer.id, + id: 'window_test', + parentId: dormer.id, + }) + const nodes = { [roof.id]: roof, [segment.id]: segment, [dormer.id]: dormer } + + expect(resolveWindowHandlePortalTarget(window, { get: (id) => nodes[id] })).toBe(roof.id) + }) + + test('keeps the level portal for wall-hosted windows', () => { + const level = LevelNode.parse({ id: 'level_test' }) + const wall = WallNode.parse({ + end: [4, 0], + id: 'wall_test', + parentId: level.id, + start: [0, 0], + }) + const window = WindowNode.parse({ id: 'window_test', parentId: wall.id, wallId: wall.id }) + const nodes = { [level.id]: level, [wall.id]: wall } + + expect(resolveWindowHandlePortalTarget(window, { get: (id) => nodes[id] })).toBe(level.id) + }) + + test('keeps every resize arrow inside the complete dormer wall', () => { + const dormer = DormerNode.parse({ + depth: 2, + height: 1, + id: 'dormer_test', + wallSkirtHeight: 2, + width: 4, + }) + const window = WindowNode.parse({ + dormerFace: 'front', + dormerId: dormer.id, + height: 1, + id: 'window_test', + parentId: dormer.id, + position: [0.5, -0.5, 0], + width: 1, + }) + const scene = sceneWith(dormer, window) + + expect(handleMax(0, window, scene)).toBe(3) + expect(handleMax(1, window, scene)).toBe(2) + expect(handleMax(2, window, scene)).toBe(2) + expect(handleMax(3, window, scene)).toBe(2) + expect(resizeToMax(0, window, scene)).toMatchObject({ position: [-0.5, -0.5, 0], width: 3 }) + expect(resizeToMax(1, window, scene)).toMatchObject({ position: [1, -0.5, 0], width: 2 }) + expect(resizeToMax(2, window, scene)).toMatchObject({ height: 2, position: [0.5, 0, 0] }) + expect(resizeToMax(3, window, scene)).toMatchObject({ height: 2, position: [0.5, -1, 0] }) + }) + + test('uses dormer depth as the resize width on a side face', () => { + const dormer = DormerNode.parse({ depth: 2, id: 'dormer_test', width: 4 }) + const window = WindowNode.parse({ + dormerFace: 'right', + dormerId: dormer.id, + id: 'window_test', + parentId: dormer.id, + position: [0, -0.5, 0], + width: 1, + }) + + expect(handleMax(1, window, sceneWith(dormer, window))).toBe(1.5) + }) + + test('reverses the face boundary for a flipped dormer window', () => { + const dormer = DormerNode.parse({ id: 'dormer_test', width: 4 }) + const window = WindowNode.parse({ + dormerFace: 'front', + dormerId: dormer.id, + id: 'window_test', + parentId: dormer.id, + position: [0.5, -0.5, 0], + rotation: [0, Math.PI, 0], + width: 1, + }) + const scene = sceneWith(dormer, window) + + expect(handleMax(0, window, scene)).toBe(2) + expect(handleMax(1, window, scene)).toBe(3) + }) + + test('lets the top arrow use the sloped upper wall of a shed dormer', () => { + const dormer = DormerNode.parse({ + depth: 4, + height: 1, + id: 'dormer_test', + roofHeight: 2, + roofType: 'shed', + shedHighSide: 'back', + wallSkirtHeight: 2, + width: 4, + }) + const window = WindowNode.parse({ + dormerFace: 'right', + dormerId: dormer.id, + height: 1, + id: 'window_test', + parentId: dormer.id, + position: [1, -0.5, 0], + width: 1, + }) + + expect(handleMax(2, window, sceneWith(dormer, window))).toBeCloseTo(3.25) + }) + + test('stops a width arrow at the shed wall slope', () => { + const dormer = DormerNode.parse({ + depth: 4, + height: 1, + id: 'dormer_test', + roofHeight: 2, + roofType: 'shed', + shedHighSide: 'back', + width: 4, + }) + const window = WindowNode.parse({ + dormerFace: 'right', + dormerId: dormer.id, + height: 1, + id: 'window_test', + parentId: dormer.id, + position: [1, 1.5, 0], + width: 1, + }) + + expect(handleMax(0, window, sceneWith(dormer, window))).toBeCloseTo(1.5) + }) +}) diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index ef050209fa..8f12a8eefc 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -1,11 +1,17 @@ import type { AnyNodeId, + DormerNode, HandleDescriptor, NodeDefinition, RoofSegmentNode, + SceneApi, WallNode, WindowNode as WindowNodeType, } from '@pascal-app/core' +import { + getDormerWallHorizontalBoundsAtHeight, + getDormerWallOpeningVerticalBounds, +} from '@pascal-app/core' import type { FloorplanNodeExtension } from '@pascal-app/editor' import { buildWindowFloorplanSchedule, @@ -29,9 +35,22 @@ const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 const MIN_WINDOW_HEIGHT = 0.3 const MIN_WINDOW_WIDTH = 0.3 -// How far the move cross floats off the wall face (+Z, the window's facing -// normal) so it's grabbable instead of buried in the sash/frame. -const MOVE_HANDLE_LIFT = 0.12 + +export function resolveWindowHandlePortalTarget( + window: WindowNodeType, + scene: Pick, +): AnyNodeId | null { + const parentId = window.parentId as AnyNodeId | null + if (!parentId) return null + const grandparentId = (scene.get(parentId) as { parentId?: AnyNodeId | null } | undefined) + ?.parentId + if (!grandparentId) return null + if (window.dormerId !== parentId) return grandparentId + return ( + (scene.get(grandparentId) as { parentId?: AnyNodeId | null } | undefined)?.parentId ?? + grandparentId + ) +} function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { if (!w.wallId) return Number.POSITIVE_INFINITY @@ -40,6 +59,50 @@ function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unkn return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } +function resolveDormerHost( + window: WindowNodeType, + scene: Pick, +): DormerNode | null { + const dormerId = window.dormerId ?? window.parentId + if (!dormerId) return null + const dormer = scene.get(dormerId as AnyNodeId) as DormerNode | undefined + return dormer?.type === 'dormer' ? dormer : null +} + +function readDormerFaceWidthMax( + window: WindowNodeType, + scene: Pick, + localGrowSign: number, +): number | null { + const dormer = resolveDormerHost(window, scene) + if (!dormer) return null + const bounds = getDormerWallHorizontalBoundsAtHeight( + dormer, + window.dormerFace ?? 'front', + window.position[1] + window.height / 2, + ) + const faceGrowSign = Math.cos(window.rotation[1]) >= 0 ? localGrowSign : -localGrowSign + const anchorX = window.position[0] - (faceGrowSign * window.width) / 2 + return faceGrowSign > 0 ? bounds.max - anchorX : anchorX - bounds.min +} + +function readDormerFaceHeightMax( + window: WindowNodeType, + scene: Pick, + growSign: number, +): number | null { + const dormer = resolveDormerHost(window, scene) + if (!dormer) return null + const bounds = getDormerWallOpeningVerticalBounds( + dormer, + window.dormerFace ?? 'front', + window.position[0], + window.width, + ) + const anchorY = window.position[1] - (growSign * window.height) / 2 + return growSign > 0 ? bounds.max - anchorY : anchorY - bounds.min +} + function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor { const sign = side === 'right' ? 1 : -1 return { @@ -49,8 +112,11 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const dormerMax = readDormerFaceWidthMax(n, scene, sign) + if (dormerMax !== null) return Math.max(MIN_WINDOW_WIDTH, dormerMax) // Roof-hosted windows clamp against the face profile (the // wall-based limits read Infinity when wallId is unset). const roofMax = readRoofFaceWidthMax(n, scene, sign) @@ -79,6 +145,7 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor (side === 'right' ? 0 : Math.PI), }, portal: 'grandparent', + portalTarget: resolveWindowHandlePortalTarget, } } @@ -92,8 +159,11 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor { + const dormerMax = readDormerFaceHeightMax(n, scene, sign) + if (dormerMax !== null) return Math.max(MIN_WINDOW_HEIGHT, dormerMax) const roofMax = readRoofFaceHeightMax(n, scene, sign) if (roofMax !== null) return Math.max(MIN_WINDOW_HEIGHT, roofMax) // Maximum: distance from the anchored edge to the wall's allowed Y @@ -123,29 +193,11 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor [0, sign * (n.height / 2 + HEIGHT_HANDLE_OFFSET), 0], }, portal: 'grandparent', - } -} - -// Press-drag move grip at the window centre, standing in the wall face. Routes -// through the same move tool as the floating Move button (3D -// `affordanceTools.move`, 2D `floorplanMoveTarget`) — slide within the wall -// plane + re-host onto another wall — committing on release, no second click. -function windowMoveHandle(): HandleDescriptor { - return { - kind: 'tap-action', - shape: 'move-cross', - plane: 'node-normal', - portal: 'grandparent', - cursor: 'move', - onActivate: (node, _scene, editor) => editor.engageMoveDrag(node), - placement: { - position: () => [0, 0, MOVE_HANDLE_LIFT], - }, + portalTarget: resolveWindowHandlePortalTarget, } } const windowHandles: HandleDescriptor[] = [ - windowMoveHandle(), windowWidthHandle('left'), windowWidthHandle('right'), windowHeightHandle('top'), @@ -167,7 +219,7 @@ export const windowDefinition: NodeDefinition = { kind: 'window', snapProfile: 'item', facingIndicator: true, - schemaVersion: 2, + schemaVersion: 3, schema: WindowNode, category: 'structure', extensions: { @@ -199,9 +251,9 @@ export const windowDefinition: NodeDefinition = { cutScope: 'wall', dirtyHandledByOwnSystem: true, }, - // `wallId` / `roofSegmentId` are re-derived from the surface under + // `wallId` / `roofSegmentId` / `dormerId` are re-derived from the surface under // the cursor at preset placement time — see door for the pattern. - hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], + hostRefFields: ['wallId', 'roofSegmentId', 'roofFace', 'dormerId', 'dormerFace'], // Frame / glass slots painted through the registry. The window system tags // each mesh with its `userData.slotId`; paint writes `node.slots`. slots: () => windowSlots(), diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 9fa359cbdd..ee1eb9d0e0 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -253,6 +253,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // the 3D move + the shared `resolveOpeningPlacement`. const collides = hasWallChildOverlap( lastValid.parentId, + useScene.getState().nodes, lastValid.position[0], lastValid.position[1], live.width, diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 432510c2d5..651113ed2e 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,5 +1,7 @@ import { type AnyNodeId, + type DormerEvent, + dormerWallFacePointToDormer, emitter, type GridEvent, holdHiddenWallPointerEvents, @@ -8,9 +10,11 @@ import { type RoofNode, sceneRegistry, spatialGridManager, + useLiveNodeOverrides, useLiveTransforms, useScene, type WallEvent, + type WindowEvent, WindowNode, } from '@pascal-app/core' import { @@ -29,11 +33,18 @@ import { useAlignmentGuides, useEditor, useFacingPose, + useRegistryToolContext, } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { + type DormerWindowTarget, + dormerEventFromHostedWindow, + getDormerWindowWorldNormal, + getDormerWindowWorldYaw, + resolveDormerWindowTarget, +} from '../shared/dormer-wall-opening-placement' import { clearOpeningGuides3D, publishOpeningGuidesForWallEvent, @@ -85,6 +96,7 @@ const edgeMaterial = new LineBasicNodeMaterial({ * new window entirely). On cancel: deletes the node. */ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => { + const { activeLevelId, isCameraDragging, selectNode } = useRegistryToolContext() const cursorGroupRef = useRef(null!) // The window preview ghost. Shown for the WHOLE move so the user always sees @@ -151,6 +163,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: movingWindowNode.side, parentId: movingWindowNode.parentId, wallId: movingWindowNode.wallId, + dormerId: movingWindowNode.dormerId, + dormerFace: movingWindowNode.dormerFace, // Windows can be hosted on a roof-segment wall face. Moving onto a // wall re-anchors as wall-hosted (roofSegmentId cleared); reverts // must restore the roof host. @@ -244,6 +258,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode event: WallEvent } | null = null let lastRoofEvent: RoofEvent | null = null + let lastDormerEvent: DormerEvent | null = null + let lastDormerTarget: DormerWindowTarget | null = null const markHostDirty = (hostId: string | null) => { if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId) @@ -260,7 +276,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } } - const getLevelId = () => useViewer.getState().selection.levelId + const getLevelId = () => activeLevelId const getLevelYOffset = () => { const id = getLevelId() return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 @@ -400,6 +416,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const valid = !hasWallChildOverlap( event.node.id, + useScene.getState().nodes, clampedX, clampedY, movingWindowNode.width, @@ -431,6 +448,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // ghost so validity reads at a glance (see MoveDoorTool). The node position // is still written so the wall cuts the hole at the right spot. if (currentHostId !== target.wallId) { + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useScene.getState().updateNode(movingWindowNode.id, { position: [target.clampedX, target.clampedY, 0], rotation: [0, target.itemRotation, 0], @@ -609,6 +627,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + dormerId: original.dormerId, + dormerFace: original.dormerFace, roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, @@ -639,7 +659,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode triggerSFX('sfx:structure-build') hideCursor() - useViewer.getState().setSelection({ selectedIds: [placedId] }) + selectNode(placedId as AnyNodeId) exitMoveMode() } @@ -697,6 +717,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode freeFollowing = true lastTarget = null lastRoofEvent = null + lastDormerEvent = null + lastDormerTarget = null // No snap SFX here: the free-follow fires off-wall (an invalid red ghost, // not a placeable position) AND interleaves with the on-wall slide on the // same pointer move (R3F `wall:move` and DOM `grid:move` carry different @@ -704,6 +726,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // source of the constant click while sliding a window along a wall — the // on-wall `applyPreview` already ticks once per along-wall cell. hideCursor() + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id) const levelId = getLevelId() const sillCenterY = getSillCenterY() @@ -723,7 +746,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) currentHostId = levelId } else { - useScene.getState().updateNode(movingWindowNode.id, { + useLiveNodeOverrides.getState().set(movingWindowNode.id, { position: [localX, sillCenterY, localZ], rotation: [0, yaw, 0], side: sideOverride, @@ -746,7 +769,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onGridMove = (event: GridEvent) => { if (committed) return - if (useViewer.getState().cameraDragging) return + if (isCameraDragging()) return // A wall/roof handler owns the pointer right now — the cursor ray is on a // wall/roof that snaps, so skip the floor follow (see `wallOwnsPointer`). if (wallOwnsPointer()) return @@ -755,6 +778,213 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode freeFollowAt(x, z) } + // ── Dormer wall faces ────────────────────────────────────────── + const resolveDormerMoveTarget = (event: DormerEvent) => + resolveDormerWindowTarget({ + event, + width: movingWindowNode.width, + height: movingWindowNode.height, + ignoreId: movingWindowNode.id, + nodes: useScene.getState().nodes, + snap: snapToHalf, + }) + + const dormerWindowWorldPosition = (event: DormerEvent, target: DormerWindowTarget) => { + const point = new Vector3( + ...dormerWallFacePointToDormer(event.node, target.face, target.position), + ) + event.object.localToWorld(point) + return [point.x, point.y, point.z] as [number, number, number] + } + + const applyDormerPreview = (event: DormerEvent, target: DormerWindowTarget) => { + markWallOwnedPointer() + freeFollowing = false + lastTarget = null + lastRoofEvent = null + lastDormerEvent = event + lastDormerTarget = target + dragAnchor = null + grabWallId = null + + const side = sideOverride ?? 'front' + const rotation: [number, number, number] = [0, side === 'back' ? Math.PI : 0, 0] + if (currentHostId !== target.dormer.id) { + useLiveNodeOverrides.getState().clear(movingWindowNode.id) + useScene.getState().updateNode(movingWindowNode.id, { + position: target.position, + rotation, + side, + parentId: target.dormer.id, + dormerId: target.dormer.id, + dormerFace: target.face, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + visible: false, + }) + markHostDirty(currentHostId) + currentHostId = target.dormer.id + } + useLiveNodeOverrides.getState().set(movingWindowNode.id, { + position: target.position, + rotation, + side, + parentId: target.dormer.id, + dormerId: target.dormer.id, + dormerFace: target.face, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + visible: false, + }) + const worldPosition = dormerWindowWorldPosition(event, target) + publishPlacementSurface( + new Vector3(...worldPosition), + getDormerWindowWorldNormal(event, target), + ) + setGhostPose({ + position: worldPosition, + rotationY: getDormerWindowWorldYaw(event, target), + tint: target.valid || altHeld ? 'valid' : 'invalid', + floorY: worldPosition[1], + side, + }) + useFacingPose.getState().clear() + clearOpeningGuides3D() + } + + const commitToDormer = (event: DormerEvent, target: DormerWindowTarget) => { + if (committed) return + committed = true + useLiveNodeOverrides.getState().clear(movingWindowNode.id) + const side = sideOverride ?? 'front' + const rotation: [number, number, number] = [0, side === 'back' ? Math.PI : 0, 0] + let placedId: string + + if (isNew) { + useScene.getState().deleteNode(movingWindowNode.id) + const cloned = structuredClone(movingWindowNode) as any + delete cloned.id + cloned.metadata = stripPlacementMetadataFlags(cloned.metadata) + const committedNode = WindowNode.parse({ + ...cloned, + position: target.position, + rotation, + side, + parentId: target.dormer.id, + dormerId: target.dormer.id, + dormerFace: target.face, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + visible: true, + }) + history.commitStep(() => { + useScene.getState().createNode(committedNode, target.dormer.id as AnyNodeId) + }) + placedId = committedNode.id + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + dormerId: original.dormerId, + dormerFace: original.dormerFace, + roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, + metadata: original.metadata, + visible: original.visible, + }) + history.commitStep(() => { + useScene.getState().updateNode(movingWindowNode.id, { + position: target.position, + rotation, + side, + parentId: target.dormer.id, + dormerId: target.dormer.id, + dormerFace: target.face, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + metadata: {}, + visible: true, + }) + }) + if (original.parentId && original.parentId !== target.dormer.id) { + markHostDirty(original.parentId) + } + placedId = movingWindowNode.id + } + + markHostDirty(target.dormer.id) + useLiveTransforms.getState().clear(movingWindowNode.id) + triggerSFX('sfx:structure-build') + hideCursor() + selectNode(placedId as AnyNodeId) + exitMoveMode() + event.stopPropagation() + } + + const onDormerHover = (event: DormerEvent) => { + if (committed) return + const target = resolveDormerMoveTarget(event) + if (!target) { + onDormerLeave() + return + } + applyDormerPreview(event, target) + event.stopPropagation() + } + + const onDormerClick = (event: DormerEvent) => { + if (committed) return + const target = + lastDormerTarget && lastDormerEvent?.node.id === event.node.id + ? lastDormerTarget + : resolveDormerMoveTarget(event) + if (!target) return + if (!target.valid && event.nativeEvent?.altKey !== true) return + commitToDormer(event, target) + } + + const onDormerLeave = () => { + hideCursor() + useLiveNodeOverrides.getState().clear(movingWindowNode.id) + useLiveTransforms.getState().clear(movingWindowNode.id) + lastDormerEvent = null + lastDormerTarget = null + } + + const dormerEventFromWindow = (event: WindowEvent): DormerEvent | null => { + const dormerId = event.node.dormerId ?? event.node.parentId + const dormer = dormerId ? useScene.getState().nodes[dormerId as AnyNodeId] : undefined + const object = dormer ? sceneRegistry.nodes.get(dormer.id as AnyNodeId) : undefined + if (!(dormer?.type === 'dormer' && object)) return null + return dormerEventFromHostedWindow(event, dormer, object) + } + + const onDormerWindowHover = (event: WindowEvent) => { + const dormerEvent = dormerEventFromWindow(event) + if (dormerEvent) onDormerHover(dormerEvent) + } + + const onDormerWindowClick = (event: WindowEvent) => { + const dormerEvent = dormerEventFromWindow(event) + if (dormerEvent) onDormerClick(dormerEvent) + } + + const onDormerWindowLeave = (event: WindowEvent) => { + if ( + event.node.dormerId || + useScene.getState().nodes[event.node.parentId as AnyNodeId]?.type === 'dormer' + ) { + onDormerLeave() + } + } + // ── Roof-segment wall faces ───────────────────────────────────── // Mirrors the wall flow for the segments' vertical wall faces (base // walls under the roof + coplanar gable ends — a window can sit in @@ -796,12 +1026,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode grabWallId = null lastTarget = null lastRoofEvent = event + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id) // Opening guides are wall-specific; clear them when over a roof face. clearOpeningGuides3D() // On a roof face the real mesh is the preview — drop the ghost + reveal. revealRealNode() if (currentHostId !== target.segment.id) { + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useScene.getState().updateNode(movingWindowNode.id, { position: target.position, rotation: [0, 0, 0], @@ -815,7 +1047,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode markHostDirty(currentHostId) currentHostId = target.segment.id } else { - useScene.getState().updateNode(movingWindowNode.id, { + useLiveNodeOverrides.getState().set(movingWindowNode.id, { position: target.position, rotation: [0, 0, 0], roofFace: target.face.id, @@ -869,6 +1101,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + dormerId: original.dormerId, + dormerFace: original.dormerFace, roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, @@ -900,7 +1134,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode triggerSFX('sfx:structure-build') hideCursor() - useViewer.getState().setSelection({ selectedIds: [placedId] }) + selectNode(placedId as AnyNodeId) exitMoveMode() event.stopPropagation() } @@ -909,6 +1143,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Mirror onWallLeave: don't revert to origin here — onGridMove takes // over on the same pointermove (snap to a nearby wall or free-follow). hideCursor() + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id) dragAnchor = null lastTarget = null @@ -916,6 +1151,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onCancel = () => { + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id) if (isNew) { useScene.getState().deleteNode(movingWindowNode.id) @@ -927,6 +1163,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + dormerId: original.dormerId, + dormerFace: original.dormerFace, roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, @@ -953,6 +1191,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode return } if (lastRoofEvent) onRoofClick(lastRoofEvent) + if (lastDormerEvent && lastDormerTarget) commitToDormer(lastDormerEvent, lastDormerTarget) } // R flips the window's facing side mid-placement (front ↔ back), like the @@ -982,6 +1221,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode lastTarget = next applyPreview(next) } + } else if (lastDormerEvent) { + const next = resolveDormerMoveTarget(lastDormerEvent) + if (next) applyDormerPreview(lastDormerEvent, next) } else if (lastFloorPoint) { // Free-following: re-run at the same spot so the floating ghost rebuilds // with the flipped side. @@ -1014,6 +1256,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) + emitter.on('dormer:enter', onDormerHover) + emitter.on('dormer:move', onDormerHover) + emitter.on('dormer:click', onDormerClick) + emitter.on('dormer:leave', onDormerLeave) + emitter.on('window:enter', onDormerWindowHover) + emitter.on('window:move', onDormerWindowHover) + emitter.on('window:click', onDormerWindowClick) + emitter.on('window:leave', onDormerWindowLeave) emitter.on('grid:move', onGridMove) emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) @@ -1078,6 +1328,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode side: original.side, parentId: original.parentId, wallId: original.wallId, + dormerId: original.dormerId, + dormerFace: original.dormerFace, roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, @@ -1091,6 +1343,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // becomes an invisible orphan (place-preset deletes a true cancel). useScene.getState().updateNode(movingWindowNode.id, { visible: true }) } + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id) useAlignmentGuides.getState().clear() clearOpeningGuides3D() @@ -1106,6 +1359,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) + emitter.off('dormer:enter', onDormerHover) + emitter.off('dormer:move', onDormerHover) + emitter.off('dormer:click', onDormerClick) + emitter.off('dormer:leave', onDormerLeave) + emitter.off('window:enter', onDormerWindowHover) + emitter.off('window:move', onDormerWindowHover) + emitter.off('window:click', onDormerWindowClick) + emitter.off('window:leave', onDormerWindowLeave) emitter.off('grid:move', onGridMove) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) @@ -1113,7 +1374,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode window.removeEventListener('keydown', onAltToggle) window.removeEventListener('keyup', onAltToggle) } - }, [movingWindowNode, exitMoveMode]) + }, [activeLevelId, exitMoveMode, isCameraDragging, movingWindowNode, selectNode]) const edgesGeo = useMemo(() => { const boxGeo = new BoxGeometry( diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index 1636f69123..e496329205 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -216,6 +216,8 @@ export default function WindowPanel() { rotation: [...node.rotation] as [number, number, number], side: node.side, wallId: node.wallId, + dormerId: node.dormerId, + dormerFace: node.dormerFace, roofSegmentId: node.roofSegmentId, roofFace: node.roofFace, parentId: node.parentId, diff --git a/packages/nodes/src/window/renderer.tsx b/packages/nodes/src/window/renderer.tsx index e10cd5bf69..2073bfd72d 100644 --- a/packages/nodes/src/window/renderer.tsx +++ b/packages/nodes/src/window/renderer.tsx @@ -20,10 +20,8 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'window') const shading = useViewer((s) => s.shading) - const liveVisible = useLiveNodeOverrides((s) => { - const visible = s.get(node.id)?.visible - return typeof visible === 'boolean' ? visible : undefined - }) + const liveOverrides = useLiveNodeOverrides((s) => s.get(node.id)) + const renderNode = liveOverrides ? ({ ...node, ...liveOverrides } as WindowNode) : node const isTransient = !!(node.metadata as Record | null)?.isTransient const material = useMemo(() => { @@ -41,19 +39,19 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { const mesh = ( ) - if (!node.roofSegmentId) return mesh + if (!renderNode.roofSegmentId) return mesh return ( - + {mesh} ) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 90482adda1..9458fea098 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -1,38 +1,53 @@ import { type AnyNode, type AnyNodeId, + type DormerEvent, + type DormerNode, + dormerWallFacePointToDormer, emitter, type GridEvent, + getEffectiveNode, holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, type RoofNode, sceneRegistry, spatialGridManager, + useLiveNodeOverrides, useScene, type WallEvent, type WallNode, WallNode as WallNodeSchema, + type WindowEvent, WindowNode, } from '@pascal-app/core' import { calculateCursorRotation, calculateItemRotation, + clearPlacementSurface, EDITOR_LAYER, getSideFromNormal, isMagneticSnapActive, isValidWallSideFace, + publishPlacementSurface, snapToHalf, triggerSFX, useAlignmentGuides, useEditor, useFacingPose, usePlacementPreview, + useRegistryToolContext, } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { + type DormerWindowTarget, + dormerEventFromHostedWindow, + getDormerWindowWorldNormal, + getDormerWindowWorldYaw, + resolveDormerWindowTarget, +} from '../shared/dormer-wall-opening-placement' import { clearOpeningGuides3D, publishOpeningGuidesForWallEvent, @@ -77,7 +92,7 @@ const roofFallbackPoint = new Vector3() // What currently owns the cursor frame: a wall/roof mesh hover, or null when // the cursor is over open floor (the grid handler then free-follows). -type HostKind = 'wall' | 'roof' | null +type HostKind = 'wall' | 'roof' | 'dormer' | null /** * Window tool — places WindowNodes on walls and on roof-segment wall @@ -90,6 +105,7 @@ type HostKind = 'wall' | 'roof' | null * engages only on an actual mesh hover — no proximity magnet. */ const WindowTool: React.FC = () => { + const { activeLevelId, isCameraDragging, selectNode } = useRegistryToolContext() const draftRef = useRef(null) const cursorGroupRef = useRef(null!) const edgesRef = useRef(null!) @@ -148,7 +164,7 @@ const WindowTool: React.FC = () => { const live = useScene.getState().nodes[draft.id as AnyNodeId] if (live?.type !== 'window') return draftRef.current = live - publishPlacementPreview(live, parentNode) + publishPlacementPreview(getEffectiveNode(live), parentNode) } let hostKind: HostKind = null @@ -163,11 +179,12 @@ const WindowTool: React.FC = () => { // to the last wall hover so the flip shows live before commit. let sideFlip = false let lastWallEvent: WallEvent | null = null + let lastDormerEvent: DormerEvent | null = null // Last open-floor cursor point (level-local X/Z) + floor Y, so an R-flip // while free-following can re-render the floating ghost with the new facing. let lastFloorPoint: { pos: [number, number, number]; floorY: number } | null = null - const getLevelId = () => useViewer.getState().selection.levelId + const getLevelId = () => activeLevelId const getLevelYOffset = () => { const id = getLevelId() return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 @@ -193,6 +210,7 @@ const WindowTool: React.FC = () => { return } const wallId = draft.parentId + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) draftRef.current = null clearPlacementPreview() @@ -206,6 +224,7 @@ const WindowTool: React.FC = () => { clearOpeningGuides3D() setFallbackPose(null) useFacingPose.getState().clear() + clearPlacementSurface() clearPlacementPreview() } @@ -291,6 +310,55 @@ const WindowTool: React.FC = () => { ) } + const dormerWindowWorldPosition = (event: DormerEvent, target: DormerWindowTarget) => { + const point = roofFallbackPoint.set( + ...dormerWallFacePointToDormer(event.node, target.face, target.position), + ) + event.object.localToWorld(point) + return worldToSelectedBuildingLocal(point) + } + + const applyDormerTarget = (event: DormerEvent, target: DormerWindowTarget) => { + const side = sideFlip ? 'back' : 'front' + const itemRotation = sideFlip ? Math.PI : 0 + + if (draftRef.current && draftRef.current.parentId !== event.node.id) destroyDraft() + if (!draftRef.current) { + const node = WindowNode.parse({ + position: target.position, + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + dormerId: event.node.id, + dormerFace: target.face, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, event.node.id as AnyNodeId) + draftRef.current = node + } else { + useLiveNodeOverrides.getState().set(draftRef.current.id, { + position: target.position, + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + dormerId: event.node.id, + dormerFace: target.face, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + }) + } + + publishDraftPreview(event.node) + clearOpeningGuides3D() + const worldPosition = dormerWindowWorldPosition(event, target) + publishPlacementSurface( + new Vector3(...worldPosition), + getDormerWindowWorldNormal(event, target), + ) + updateCursor(worldPosition, getDormerWindowWorldYaw(event, target), target.valid, 0) + } + // Sill alignment (snap + guide): a sibling sill/centre/top wins over the // grid when within threshold — it's the magnetic ("lines") component for the // vertical axis, so it runs only when magnetic snap is on; otherwise the @@ -355,7 +423,15 @@ const WindowTool: React.FC = () => { height, useScene.getState().nodes, ) - const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) + const valid = !hasWallChildOverlap( + wall.id, + useScene.getState().nodes, + clampedX, + clampedY, + width, + height, + ignoreId, + ) return { clampedX, clampedY, valid } } @@ -400,13 +476,14 @@ const WindowTool: React.FC = () => { ) if (wall.id === draftRef.current.parentId) { - useScene.getState().updateNode(draftRef.current.id, { + useLiveNodeOverrides.getState().set(draftRef.current.id, { position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, }) markHostDirty(wall.id) } else { + useLiveNodeOverrides.getState().clear(draftRef.current.id) useScene.getState().updateNode(draftRef.current.id, { position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], @@ -464,6 +541,7 @@ const WindowTool: React.FC = () => { draftRef.current = null hostKind = null + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -501,7 +579,7 @@ const WindowTool: React.FC = () => { }) useScene.getState().createNode(node, wall.id as AnyNodeId) - useViewer.getState().setSelection({ selectedIds: [node.id] }) + selectNode(node.id) triggerSFX('sfx:structure-build') useAlignmentGuides.getState().clear() clearOpeningGuides3D() @@ -514,6 +592,66 @@ const WindowTool: React.FC = () => { } } + const commitWindowAtDormer = (dormer: DormerNode, target: DormerWindowTarget) => { + const draft = draftRef.current + if (!draft) return + clearPlacementPreview() + draftRef.current = null + hostKind = null + + useLiveNodeOverrides.getState().clear(draft.id) + useScene.getState().deleteNode(draft.id) + useScene.temporal.getState().resume() + + const state = useScene.getState() + const windowCount = Object.values(state.nodes).filter((node) => node.type === 'window').length + const side = sideFlip ? 'back' : 'front' + const node = WindowNode.parse({ + name: `Window ${windowCount + 1}`, + position: target.position, + rotation: [0, sideFlip ? Math.PI : 0, 0], + side, + parentId: dormer.id, + dormerId: dormer.id, + dormerFace: target.face, + width: draft.width, + height: draft.height, + material: draft.material, + slots: draft.slots, + openingKind: draft.openingKind, + windowType: draft.windowType, + operationState: draft.operationState, + awningDirection: draft.awningDirection, + casementStyle: draft.casementStyle, + hingesSide: draft.hingesSide, + openingShape: draft.openingShape, + openingRadiusMode: draft.openingRadiusMode, + openingCornerRadii: draft.openingCornerRadii, + cornerRadius: draft.cornerRadius, + archHeight: draft.archHeight, + frameThickness: draft.frameThickness, + frameDepth: draft.frameDepth, + columnRatios: draft.columnRatios, + rowRatios: draft.rowRatios, + columnDividerThickness: draft.columnDividerThickness, + rowDividerThickness: draft.rowDividerThickness, + sill: draft.sill, + sillDepth: draft.sillDepth, + sillThickness: draft.sillThickness, + }) + + state.createNode(node, dormer.id as AnyNodeId) + state.dirtyNodes.add(dormer.id as AnyNodeId) + selectNode(node.id) + triggerSFX('sfx:structure-build') + if (useEditor.getState().getContinuation('point') === 'repeat') { + useScene.temporal.getState().pause() + } else { + hideCursor() + useEditor.getState().setTool(null) + } + } + // ── Direct wall-mesh hover ────────────────────────────────────── const onWallHover = (event: WallEvent) => { hostKind = 'wall' @@ -591,7 +729,7 @@ const WindowTool: React.FC = () => { // NOT snap from proximity — snapping engages only when the cursor ray // actually hovers a wall (onWallHover) or roof face (onRoofHover). const onGridFreeFollow = (event: GridEvent) => { - if (useViewer.getState().cameraDragging) return + if (isCameraDragging()) return // A wall/roof mesh handler processed this pointermove (shared DOM // timeStamp) — it owns the frame and has snapped the draft, so skip the // floor follow this tick. @@ -606,6 +744,88 @@ const WindowTool: React.FC = () => { showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], y) } + // ── Dormer wall faces ────────────────────────────────────────── + // Dormer windows use the same WindowNode mesh and inspector as regular + // windows, but their host frame is supplied by DormerRenderer. + const resolveDormerTarget = (event: DormerEvent) => + resolveDormerWindowTarget({ + event, + width: draftRef.current?.width ?? FALLBACK_WIDTH, + height: draftRef.current?.height ?? FALLBACK_HEIGHT, + nodes: useScene.getState().nodes, + ignoreId: draftRef.current?.id, + snap: snapToHalf, + }) + + const showDormerFallbackCursor = (event: DormerEvent) => { + const [x, y, z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) + showGhostAt([x, y, z], y) + } + + const onDormerHover = (event: DormerEvent) => { + hostKind = 'dormer' + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 + lastDormerEvent = event + const target = resolveDormerTarget(event) + if (!target) { + destroyDraft() + showDormerFallbackCursor(event) + return + } + applyDormerTarget(event, target) + event.stopPropagation() + } + + const onDormerClick = (event: DormerEvent) => { + if (!draftRef.current || draftRef.current.parentId !== event.node.id) return + const target = resolveDormerTarget(event) + if (!target) return + if (!target.valid && event.nativeEvent?.altKey !== true) return + commitWindowAtDormer(event.node, target) + event.stopPropagation() + } + + const onDormerLeave = () => { + if (hostKind !== 'dormer') return + lastDormerEvent = null + destroyDraft() + hideCursor() + hostKind = null + } + + // The default dormer window is a real WindowNode and therefore sits in + // front of the dormer body for raycasting. While placing another window, + // translate hits on that child back into a dormer-local event so the + // placement tool does not fall through to the ground ghost. + const dormerEventFromWindow = (event: WindowEvent): DormerEvent | null => { + const dormerId = event.node.dormerId ?? event.node.parentId + const dormer = dormerId + ? (useScene.getState().nodes[dormerId as AnyNodeId] as DormerNode | undefined) + : undefined + const object = dormer ? sceneRegistry.nodes.get(dormer.id as AnyNodeId) : undefined + if (!(dormer?.type === 'dormer' && object)) return null + return dormerEventFromHostedWindow(event, dormer, object) + } + + const onDormerWindowHover = (event: WindowEvent) => { + const dormerEvent = dormerEventFromWindow(event) + if (dormerEvent) onDormerHover(dormerEvent) + } + + const onDormerWindowClick = (event: WindowEvent) => { + const dormerEvent = dormerEventFromWindow(event) + if (dormerEvent) onDormerClick(dormerEvent) + } + + const onDormerWindowLeave = (event: WindowEvent) => { + if ( + event.node.dormerId || + useScene.getState().nodes[event.node.parentId as AnyNodeId]?.type === 'dormer' + ) { + onDormerLeave() + } + } + // ── Roof-segment wall faces ───────────────────────────────────── // The merged roof mesh emits `roof:*`; hits are resolved against the // segments' vertical wall faces (base walls + coplanar gable ends), @@ -645,7 +865,7 @@ const WindowTool: React.FC = () => { if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft() if (draftRef.current) { - useScene.getState().updateNode(draftRef.current.id, { + useLiveNodeOverrides.getState().set(draftRef.current.id, { position, rotation: [0, 0, 0], roofFace: face.id, @@ -683,6 +903,7 @@ const WindowTool: React.FC = () => { draftRef.current = null hostKind = null + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -721,7 +942,7 @@ const WindowTool: React.FC = () => { // Rebuild the segment (and the merged roof) so the wall brush // picks up the new opening cut. useScene.getState().dirtyNodes.add(segment.id as AnyNodeId) - useViewer.getState().setSelection({ selectedIds: [node.id] }) + selectNode(node.id) triggerSFX('sfx:structure-build') if (useEditor.getState().getContinuation('point') === 'repeat') { useScene.temporal.getState().pause() @@ -759,6 +980,8 @@ const WindowTool: React.FC = () => { triggerSFX('sfx:item-rotate') if (lastWallEvent) { onWallHover(lastWallEvent) + } else if (lastDormerEvent) { + onDormerHover(lastDormerEvent) } else if (lastFloorPoint) { showGhostAt(lastFloorPoint.pos, lastFloorPoint.floorY) } @@ -773,6 +996,14 @@ const WindowTool: React.FC = () => { emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) + emitter.on('dormer:enter', onDormerHover) + emitter.on('dormer:move', onDormerHover) + emitter.on('dormer:click', onDormerClick) + emitter.on('dormer:leave', onDormerLeave) + emitter.on('window:enter', onDormerWindowHover) + emitter.on('window:move', onDormerWindowHover) + emitter.on('window:click', onDormerWindowClick) + emitter.on('window:leave', onDormerWindowLeave) emitter.on('grid:move', onGridFreeFollow) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) @@ -798,11 +1029,19 @@ const WindowTool: React.FC = () => { emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) + emitter.off('dormer:enter', onDormerHover) + emitter.off('dormer:move', onDormerHover) + emitter.off('dormer:click', onDormerClick) + emitter.off('dormer:leave', onDormerLeave) + emitter.off('window:enter', onDormerWindowHover) + emitter.off('window:move', onDormerWindowHover) + emitter.off('window:click', onDormerWindowClick) + emitter.off('window:leave', onDormerWindowLeave) emitter.off('grid:move', onGridFreeFollow) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) } - }, []) + }, [activeLevelId, isCameraDragging, selectNode]) // Cursor geometry: window outline rectangle. Static dims, so build it once and // dispose on unmount rather than reallocating (and orphaning) an EdgesGeometry diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index b4ecb33475..6141ebd586 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -675,9 +675,7 @@ export function createSurfaceRoleMaterial( // on `glassMaterial` above — the validator rejects the back-face variant // for missing MRT outputs and poisons the render context (manifests as // "Color target has no corresponding fragment stage output" on scene - // open, since the dormer's window-assembly mounts the glazing material - // on both gable faces on the first frame). Callers that need both sides - // visible (e.g. dormer back gable) must rotate the host mesh 180° so the + // open). Callers that need both sides visible must rotate the host mesh 180° so the // FrontSide faces the viewer. const resolvedSide = role === 'glazing' ? THREE.FrontSide : resolveNodeMaterialSide(side ?? THREE.FrontSide) diff --git a/packages/viewer/src/systems/roof/roof-system-intersection.test.ts b/packages/viewer/src/systems/roof/roof-system-intersection.test.ts index 805ba87eb2..1d7abc7ccd 100644 --- a/packages/viewer/src/systems/roof/roof-system-intersection.test.ts +++ b/packages/viewer/src/systems/roof/roof-system-intersection.test.ts @@ -13,6 +13,88 @@ function box(size: [number, number, number], position: [number, number, number]) } describe('roof system intersections', () => { + test('keeps a declared host solid and clips the mounted conical wall at its surface', () => { + const level = LevelNode.parse({ + id: 'level_conical-cut', + type: 'level', + children: ['roof_host', 'roof_conical'], + }) + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + parentId: level.id, + children: ['rseg_host'], + }) + const conicalRoof = RoofNode.parse({ + id: 'roof_conical', + type: 'roof', + parentId: level.id, + position: [0, 3.0657691454, 0], + children: ['rseg_conical'], + support: { + kind: 'roof', + roofSegmentId: 'rseg_host', + localPosition: [0, 0], + curbHeight: 0.5, + }, + }) + const host = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'gable', + width: 10, + depth: 8, + wallHeight: 2, + pitch: 25, + }) + const conical = RoofSegmentNode.parse({ + id: 'rseg_conical', + type: 'roof-segment', + parentId: conicalRoof.id, + roofType: 'conical', + width: 3, + depth: 3, + wallHeight: 1.2994614872, + pitch: 50, + }) + const nodes = { + [level.id]: level, + [hostRoof.id]: hostRoof, + [conicalRoof.id]: conicalRoof, + [host.id]: host, + [conical.id]: conical, + } + const unclipped = generateRoofSegmentGeometry(host) + const clipped = generateRoofSegmentGeometry(host, nodes) + const meshBefore = new THREE.Mesh(unclipped) + const meshAfter = new THREE.Mesh(clipped) + const hitsAt = (mesh: THREE.Mesh, x: number, z: number) => + new THREE.Raycaster(new THREE.Vector3(x, 10, z), new THREE.Vector3(0, -1, 0)).intersectObject( + mesh, + ) + + expect(hitsAt(meshBefore, 1.4, 0).length).toBeGreaterThan(0) + expect(hitsAt(meshAfter, 1.4, 0).length).toBeGreaterThan(0) + expect(Array.from(clipped.getAttribute('position').array).every(Number.isFinite)).toBe(true) + + const unclippedConical = generateRoofSegmentGeometry(conical) + const clippedConical = generateRoofSegmentGeometry(conical, nodes) + const sideHitsAt = (geometry: THREE.BufferGeometry, y: number) => + new THREE.Raycaster(new THREE.Vector3(3, y, 0), new THREE.Vector3(-1, 0, 0)).intersectObject( + new THREE.Mesh(geometry), + ) + + expect(sideHitsAt(unclippedConical, 0.7).length).toBeGreaterThan(0) + expect(sideHitsAt(clippedConical, 0.7)).toHaveLength(0) + expect(sideHitsAt(clippedConical, 0.9).length).toBeGreaterThan(0) + + unclipped.dispose() + clipped.dispose() + unclippedConical.dispose() + clippedConical.dispose() + }) + test('removes a roof layer that continues through a sibling attic', () => { const layer = box([4, 0.2, 4], [0, 1, 0]) const siblingInterior = box([2, 3, 2], [0, 1, 0]) diff --git a/packages/viewer/src/systems/roof/roof-system.test.ts b/packages/viewer/src/systems/roof/roof-system.test.ts index 31c4f65b8a..d8dd7e650a 100644 --- a/packages/viewer/src/systems/roof/roof-system.test.ts +++ b/packages/viewer/src/systems/roof/roof-system.test.ts @@ -1,7 +1,7 @@ // @ts-expect-error - bun:test is provided by the Bun runtime; viewer does not // include Bun globals in its package tsconfig. import { describe, expect, test } from 'bun:test' -import { RoofSegmentNode } from '@pascal-app/core' +import { RoofNode, RoofSegmentNode } from '@pascal-app/core' import * as THREE from 'three' import { generateRoofSegmentGeometry } from './roof-system' @@ -15,6 +15,7 @@ describe('roof system shed geometry', () => { const sideInfillX: number[] = [] const sideInfillNormals: THREE.Vector3[] = [] const roofSideX: number[] = [] + const wallVertexYs: number[] = [] const a = new THREE.Vector3() const b = new THREE.Vector3() const c = new THREE.Vector3() @@ -38,22 +39,28 @@ describe('roof system shed geometry', () => { } if (group.materialIndex === 2) { - sideInfillNormals.push(normal.clone()) - for (const vertexIndex of [ia, ib, ic]) { - const x = position.getX(vertexIndex) - const y = position.getY(vertexIndex) - if (y >= segment.wallHeight - 0.001) { - sideInfillX.push(x) + const vertexIndices = [ia, ib, ic] + for (const vertexIndex of vertexIndices) { + wallVertexYs.push(position.getY(vertexIndex)) + } + if ( + vertexIndices.every( + (vertexIndex) => position.getY(vertexIndex) >= segment.wallHeight - 0.05, + ) + ) { + sideInfillNormals.push(normal.clone()) + for (const vertexIndex of vertexIndices) { + sideInfillX.push(position.getX(vertexIndex)) } } } } } - return { geometry, roofSideX, sideInfillNormals, sideInfillX } + return { geometry, roofSideX, sideInfillNormals, sideInfillX, wallVertexYs } } - test('keeps standalone shed side infill inside the overhanging roof edge', () => { + test('keeps the standalone shed wall shell beneath the overhanging roof edge', () => { const segment = RoofSegmentNode.parse({ id: 'rseg_shed', type: 'roof-segment', @@ -68,19 +75,86 @@ describe('roof system shed geometry', () => { shingleThickness: 0.05, }) const wallSideX = segment.width / 2 - const { geometry, roofSideX, sideInfillNormals, sideInfillX } = inspectShedGeometry(segment) + const { geometry, roofSideX, wallVertexYs } = inspectShedGeometry(segment) - expect(sideInfillNormals).toHaveLength(2) - expect(sideInfillX.length).toBeGreaterThan(0) - expect(sideInfillNormals.every((panelNormal) => Math.abs(panelNormal.x) > 0.95)).toBe(true) - expect(sideInfillNormals.every((panelNormal) => Math.abs(panelNormal.z) < 0.05)).toBe(true) - expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeLessThan(wallSideX - 0.05) - expect(Math.max(...sideInfillX.map((x) => Math.abs(x)))).toBeGreaterThan(wallSideX - 0.15) + expect(wallVertexYs.length).toBeGreaterThan(0) + expect(Math.min(...wallVertexYs)).toBeLessThan(segment.wallHeight / 2) expect(Math.max(...roofSideX)).toBeGreaterThan(wallSideX + segment.overhang * 0.5) geometry.dispose() }) + test('retains the wall shell when changing a standalone segment to shed', () => { + const original = RoofSegmentNode.parse({ + id: 'rseg_switched_to_shed', + type: 'roof-segment', + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 2.6, + wallThickness: 0.1, + pitch: 25, + overhang: 0.3, + deckThickness: 0.1, + shingleThickness: 0.05, + }) + const segment = RoofSegmentNode.parse({ ...original, roofType: 'shed' }) + const geometry = generateRoofSegmentGeometry(segment) + const position = geometry.getAttribute('position') + const index = geometry.getIndex() + expect(index).not.toBeNull() + + const wallVertexYs: number[] = [] + for (const group of geometry.groups) { + if (group.materialIndex !== 2) continue + for (let offset = group.start; offset < group.start + group.count; offset += 1) { + wallVertexYs.push(position.getY(index!.getX(offset))) + } + } + + expect(wallVertexYs.length).toBeGreaterThan(0) + expect(Math.min(...wallVertexYs)).toBeLessThan(segment.wallHeight / 2) + + geometry.dispose() + }) + + test('omits overlapping wall shells from legacy composite shed roofs', () => { + const roof = RoofNode.parse({ + id: 'roof_legacy_composite_shed', + type: 'roof', + children: ['rseg_legacy_shed_a', 'rseg_legacy_shed_b'], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_legacy_shed_a', + type: 'roof-segment', + parentId: roof.id, + roofType: 'shed', + width: 8, + depth: 6, + wallHeight: 0.1, + wallThickness: 0.1, + pitch: 25, + overhang: 0.3, + deckThickness: 0.1, + shingleThickness: 0.05, + }) + const sibling = RoofSegmentNode.parse({ + ...segment, + id: 'rseg_legacy_shed_b', + position: [2, 0, 0], + rotation: Math.PI / 4, + }) + const geometry = generateRoofSegmentGeometry(segment, { + [roof.id]: roof, + [segment.id]: segment, + [sibling.id]: sibling, + }) + + expect(geometry.groups.some((group) => group.materialIndex === 2)).toBe(false) + + geometry.dispose() + }) + test('keeps configured shed side infill on the outer side-member face', () => { const span = 4 const leftOverhang = 0.15 @@ -102,6 +176,8 @@ describe('roof system shed geometry', () => { shedSideInfillSpan: span, shedSideInfillMinX: -infillHalfWidth, shedSideInfillMaxX: infillHalfWidth, + shedInsetEndPanels: true, + wallShell: 'omit', }) const { geometry, roofSideX, sideInfillNormals, sideInfillX } = inspectShedGeometry(segment) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 88577f7b95..c42151709d 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -22,6 +22,7 @@ import { roofOverlapEntryOwns, roofPlanBoundsOverlap, sceneRegistry, + unionPolygons, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -636,7 +637,7 @@ function updateMergedRoofGeometry( totalDeckSlab = brushes.deckSlab } - if (child.roofType === 'shed') { + if (!shouldIncludeRoofSegmentWallShell(child, roofNode)) { brushes.wallBrush.geometry.dispose() brushes.innerBrush.geometry.dispose() } else { @@ -1036,6 +1037,17 @@ function readShedOpenEndSides(node: RoofSegmentNode): Set { return new Set(value.filter((side): side is ShedEndSide => side === 'left' || side === 'right')) } +function shouldIncludeRoofSegmentWallShell(node: RoofSegmentNode, parentRoof?: RoofNode): boolean { + if (node.wallShell === 'include') return true + if (node.wallShell === 'omit') return false + if (node.roofType !== 'shed') return true + + // Older composite roofs use overlapping shed segments as deck pieces. Their + // wall volumes were never part of the rendered shell and make CSG grow + // exponentially when unioned together. + return !parentRoof || (parentRoof.children?.length ?? 0) <= 1 +} + function hasSegmentTrim(node: RoofSegmentNode): boolean { const trim = normalizeRoofSegmentTrim(node) return ( @@ -1430,7 +1442,7 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe let shinTopD = shinBotD let transZ = 0 - if (['hip', 'mansard', 'dutch'].includes(roofType)) { + if (['hip', 'mansard', 'dutch', 'conical'].includes(roofType)) { shinTopW += 2 * stSin shinTopD += 2 * stSin } else if (['gable', 'gambrel'].includes(roofType)) { @@ -1641,13 +1653,10 @@ export function generateRoofSegmentGeometry( node: RoofSegmentNode, nodes?: Record, ): THREE.BufferGeometry { - const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined - const parentRoofPosition = - parentRoof && 'position' in parentRoof ? (parentRoof.position as number[]) : undefined - const parentRoofRotation = - parentRoof && 'rotation' in parentRoof - ? ((parentRoof as { rotation?: number }).rotation ?? 0) - : 0 + const parentNode = node.parentId ? nodes?.[node.parentId] : undefined + const parentRoof = parentNode?.type === 'roof' ? parentNode : undefined + const parentRoofPosition = parentRoof?.position + const parentRoofRotation = parentRoof?.rotation ?? 0 const segmentWorldMatrix = composeSegmentWorldMatrix( parentRoofPosition, parentRoofRotation, @@ -1684,7 +1693,7 @@ export function generateRoofSegmentGeometry( prepareBrushForCSG(shinDeck) let combined = shinDeck let hollowWall: Brush | null = null - if (node.roofType !== 'shed') { + if (shouldIncludeRoofSegmentWallShell(node, parentRoof)) { hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION) prepareBrushForCSG(hollowWall) combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) @@ -1813,18 +1822,8 @@ function buildOccludingRoofInterior( if (sibling.id === node.id) continue if (sibling.roofType === 'shed') continue const siblingOwnsOverlap = roofOverlapEntryOwns( - { - roofId: String(entry.roof.id), - segmentId: String(sibling.id), - width: sibling.width, - depth: sibling.depth, - }, - { - roofId: String(currentEntry.roof.id), - segmentId: String(node.id), - width: node.width, - depth: node.depth, - }, + roofOverlapEntry(entry.roof, sibling, nodes), + roofOverlapEntry(currentEntry.roof, node, nodes), ) if (!siblingOwnsOverlap) continue const siblingBrushes = getRoofSegmentBrushes(sibling) @@ -1863,6 +1862,28 @@ function buildOccludingRoofInterior( return combinedInterior } +function roofOverlapEntry( + roof: RoofNode, + segment: RoofSegmentNode, + nodes: Record, +) { + const supportSegment = + roof.support?.kind === 'roof' ? nodes[roof.support.roofSegmentId as AnyNodeId] : undefined + return { + roofId: String(roof.id), + segmentId: String(segment.id), + supportRoofId: + supportSegment?.type === 'roof-segment' && supportSegment.parentId + ? String(supportSegment.parentId) + : undefined, + supportRoofSegmentId: + roof.support?.kind === 'roof' ? String(roof.support.roofSegmentId) : undefined, + roofType: segment.roofType, + width: segment.width, + depth: segment.depth, + } +} + function collectSiblingRoofEntries( targetRoof: RoofNode, nodes: Record, @@ -2108,6 +2129,33 @@ function clipRoofPolygonAtX( return clipped } +function sanitizeRoofPlanPolygon(polygon: RoofPlanPolygon): RoofPlanPolygon { + const tolerance = 1e-8 + const points = polygon.filter((point, index) => { + const previous = polygon[(index + polygon.length - 1) % polygon.length]! + return Math.hypot(point[0] - previous[0], point[1] - previous[1]) > tolerance + }) + + let changed = true + while (changed && points.length >= 3) { + changed = false + for (let index = 0; index < points.length; index++) { + const previous = points[(index + points.length - 1) % points.length]! + const point = points[index]! + const next = points[(index + 1) % points.length]! + const cross = + (point[0] - previous[0]) * (next[1] - point[1]) - + (point[1] - previous[1]) * (next[0] - point[0]) + if (Math.abs(cross) > tolerance) continue + points.splice(index, 1) + changed = true + break + } + } + + return points +} + function facetBandedRoofPieces( pieces: readonly RoofPlanPolygon[], width: number, @@ -2131,6 +2179,43 @@ function facetBandedRoofPieces( return faceted } +function facetBandedRoofBoundary( + polygon: RoofPlanPolygon, + width: number, +): [RoofPlanPolygon[number], RoofPlanPolygon[number]][] { + const facetCount = Math.max(4, Math.min(32, Math.ceil(width / 0.4))) + const halfWidth = width / 2 + const facetWidth = width / facetCount + const boundaries = Array.from( + { length: facetCount - 1 }, + (_, index) => -halfWidth + (index + 1) * facetWidth, + ) + const segments: [RoofPlanPolygon[number], RoofPlanPolygon[number]][] = [] + for (let index = 0; index < polygon.length; index++) { + const start = polygon[index]! + const end = polygon[(index + 1) % polygon.length]! + const deltaX = end[0] - start[0] + const splits = boundaries + .flatMap((boundaryX) => { + if (Math.abs(deltaX) <= 1e-8) return [] + const ratio = (boundaryX - start[0]) / deltaX + return ratio > 1e-8 && ratio < 1 - 1e-8 ? [ratio] : [] + }) + .sort((left, right) => left - right) + const points = [0, ...splits, 1].map( + (ratio) => + [ + start[0] + (end[0] - start[0]) * ratio, + start[1] + (end[1] - start[1]) * ratio, + ] as RoofPlanPolygon[number], + ) + for (let pointIndex = 0; pointIndex + 1 < points.length; pointIndex++) { + segments.push([points[pointIndex]!, points[pointIndex + 1]!]) + } + } + return segments +} + function buildCustomShedGeometry(node: RoofSegmentNode): THREE.BufferGeometry | null { if (node.roofType !== 'shed') return null const pieces = readShedFootprintPieces(node) @@ -2146,27 +2231,45 @@ function buildCustomShedGeometry(node: RoofSegmentNode): THREE.BufferGeometry | const geometries: THREE.BufferGeometry[] = [] for (const polygon of renderPieces) { - const signedArea = polygon.reduce((area, point, index) => { - const next = polygon[(index + 1) % polygon.length]! + const sanitized = sanitizeRoofPlanPolygon(polygon) + const signedArea = sanitized.reduce((area, point, index) => { + const next = sanitized[(index + 1) % sanitized.length]! return area + point[0] * next[1] - next[0] * point[1] }, 0) if (Math.abs(signedArea) <= 1e-9) continue - const outline = signedArea > 0 ? polygon : [...polygon].reverse() + const outline = signedArea > 0 ? sanitized : [...sanitized].reverse() const bottom = outline.map( ([x, z]) => new THREE.Vector3(x, getRoofSegmentSurfaceY(node, x, z), z), ) - const top = [...bottom] - .reverse() - .map((point) => new THREE.Vector3(point.x, point.y + verticalThickness, point.z)) - const faces: THREE.Vector3[][] = [bottom, top] - for (let index = 0; index < bottom.length; index++) { - const next = (index + 1) % bottom.length - faces.push([ - bottom[next]!.clone(), - bottom[index]!.clone(), - new THREE.Vector3(bottom[index]!.x, bottom[index]!.y + verticalThickness, bottom[index]!.z), - new THREE.Vector3(bottom[next]!.x, bottom[next]!.y + verticalThickness, bottom[next]!.z), - ]) + const triangles = THREE.ShapeUtils.triangulateShape( + outline.map(([x, z]) => new THREE.Vector2(x, z)), + [], + ) + const faces: THREE.Vector3[][] = triangles.flatMap((triangle) => { + const bottomFace = triangle.map((index) => bottom[index]!.clone()) + const normalY = new THREE.Vector3() + .subVectors(bottomFace[1]!, bottomFace[0]!) + .cross(new THREE.Vector3().subVectors(bottomFace[2]!, bottomFace[0]!)).y + if (normalY > 0) bottomFace.reverse() + const topFace = [...bottomFace] + .reverse() + .map((point) => new THREE.Vector3(point.x, point.y + verticalThickness, point.z)) + return [bottomFace, topFace] + }) + if (!banded) { + for (let index = 0; index < bottom.length; index++) { + const next = (index + 1) % bottom.length + faces.push([ + bottom[next]!.clone(), + bottom[index]!.clone(), + new THREE.Vector3( + bottom[index]!.x, + bottom[index]!.y + verticalThickness, + bottom[index]!.z, + ), + new THREE.Vector3(bottom[next]!.x, bottom[next]!.y + verticalThickness, bottom[next]!.z), + ]) + } } geometries.push( createGeometryFromFaces(faces, (normal) => @@ -2175,6 +2278,30 @@ function buildCustomShedGeometry(node: RoofSegmentNode): THREE.BufferGeometry | ) } + if (banded) { + const boundaryFaces = unionPolygons(pieces.map((piece) => [...piece])).flatMap((polygon) => + facetBandedRoofBoundary(sanitizeRoofPlanPolygon(polygon), node.width).map(([start, end]) => { + const startBottom = new THREE.Vector3( + start[0], + getRoofSegmentSurfaceY(node, start[0], start[1]), + start[1], + ) + const endBottom = new THREE.Vector3( + end[0], + getRoofSegmentSurfaceY(node, end[0], end[1]), + end[1], + ) + return [ + endBottom, + startBottom, + new THREE.Vector3(startBottom.x, startBottom.y + verticalThickness, startBottom.z), + new THREE.Vector3(endBottom.x, endBottom.y + verticalThickness, endBottom.z), + ] + }), + ) + geometries.push(createGeometryFromFaces(boundaryFaces, ROOF_EDGE_MATERIAL_INDEX)) + } + if (geometries.length === 0) return null const merged = mergeGeometriesPreservingGroups(geometries) for (const geometry of geometries) geometry.dispose() @@ -2863,7 +2990,9 @@ function addShedInsetEndPanels( segments: readonly RoofSegmentNode[], applySegmentTransform: boolean, ): THREE.BufferGeometry { - const shedSegments = segments.filter((segment) => segment.roofType === 'shed') + const shedSegments = segments.filter( + (segment) => segment.roofType === 'shed' && segment.shedInsetEndPanels, + ) if (shedSegments.length === 0) return geometry const panelGeometries: THREE.BufferGeometry[] = [] @@ -3115,7 +3244,7 @@ export function getRoofOuterSurfaceFrameAtPoint( let shinTopW = shinBotW let shinTopD = shinBotD let transZ = 0 - if (['hip', 'mansard', 'dutch'].includes(roofType)) { + if (['hip', 'mansard', 'dutch', 'conical'].includes(roofType)) { shinTopW += 2 * stSin shinTopD += 2 * stSin } else { @@ -3146,7 +3275,7 @@ export function getRoofOuterSurfaceFrameAtPoint( let iL = 0 let iR = 0 - if (roofType === 'hip') { + if (roofType === 'hip' || roofType === 'conical') { iF = inset iB = inset iL = inset diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 96bc92827b..4b8e368391 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -96,6 +96,11 @@ export function MyTool() { mode-positioned point, so grid quantise / angle lock / free placement are respected right up to the wall and only the last few cm stick. It is **not** a Shift bypass and must not be gated on modifiers. See `snapWallDraftPointDetailed` in `components/tools/wall/wall-drafting.ts`. + - **Sanctioned exception — lean-to structural connection snap.** Moving or resizing a + `lean-to-extension` keeps a tight, mode-independent edge/height catch to a neighboring + extension. This is connectivity: the joined roofs become one structural run with shared + gutter ends and a single joint post. It runs after the active grid/free proposal and is + bypassed only by held Alt. The same rule applies in 2D and 3D. - **Constraints and guides can be decoupled.** When a stronger constraint owns the proposal — a wall segment's 45° lock while in `angles` mode — the tool may still publish passive dashed alignment/proximity guides as long as it does not apply the guide snap delta. Use this for chained diff --git a/wiki/conical-roof-implementation-plan.md b/wiki/conical-roof-implementation-plan.md new file mode 100644 index 0000000000..684f44c89a --- /dev/null +++ b/wiki/conical-roof-implementation-plan.md @@ -0,0 +1,52 @@ +# Conical roof implementation plan + +## Decision + +Add `conical` to `RoofSegmentNode` and expose a **Conical roof** preset under Roof features. Each placement creates an independent `RoofNode` assembly containing the conical segment, so the assembly can be selected and moved separately while reusing roof materials, hosted accessories, merged-roof CSG, and sibling trimming. + +The first release is a regular cone over a circular plan. Bell, ogee, onion, and dome profiles are separate future shapes rather than settings on the cone. The terminology and option survey are recorded in [conical-turret-roof-research.md](conical-turret-roof-research.md). + +## Data contract + +- `roofType` is `conical`. +- `width` is the canonical diameter; all editor creation and resize paths keep `depth === width`. +- Pitch uses the existing roof convention. Rise is `diameter / 2 * tan(pitch)`. +- Rotation is stored for schema consistency but has no visible effect and receives no editing handle. +- `RoofNode.support` records either level placement or a roof-surface attachment. A roof attachment stores the host segment, host-local center, and curb height. +- Rectangular manual trim controls are unavailable. Intersections use the existing solid-to-solid roof trimming pipeline. +- Overhang, wall height, wall/deck/covering thickness, and surface materials remain the existing roof-segment fields. + +## Geometry and trimming + +1. Generate a closed circular wall-and-cone volume with renderer-owned radial tessellation. +2. Generate deck and covering layers through the existing inset/offset brush path. +3. Feed the solid into the existing merged-roof CSG pipeline. The declared host clips the mounted assembly at its outer surface, matching the chimney rule: the cylindrical body is removed inside the host while the host shell stays intact and the cone remains above it. Plan view keeps the mounted circle visible above the host. A level-supported cone uses the normal area-based overlap rule. +4. Recompute intersections whenever either segment moves or changes dimensions, using the existing roof dependency invalidation. +5. Keep straight-edge gutters and ridge vents unavailable for this profile. Circular eave trim and a finial are follow-up accessories. + +## Interaction + +- Build panel: selecting **Conical roof** activates the standard roof tool with a conical default. +- Placement surface: `Auto` chooses a complete roof support under the circle and otherwise uses the level. `Ground` always uses the level. `Roof` requires a complete roof support and blocks invalid commits. The contextual HUD chip and `P` cycle these modes. +- 3D placement: the two-point footprint gesture resolves to a circle whose diameter is the larger drag span. +- 2D placement: use the same resolver and committed invariant as 3D. +- Resize: any side handle changes the diameter on both axes; rotation handles are hidden. +- Inspector: show one Diameter control, pitch, wall height, overhang, structure, and materials. Hide rectangular trim, drainage, and ridge-vent automation. +- Floorplan: render and hit-test a circle; approximate it only at the polygon-union boundary used to compute the merged roof silhouette. + +## Verification + +- Schema parses and serializes `conical`. +- A diameter of 8 m at 45° produces a 4 m roof rise. +- The generated shell is closed and uses one circular eave and one apex. +- Surface height falls linearly with radial distance. +- Placement and every resize path preserve `width === depth`. +- 2D selection and hit targets are circular. +- A mounted conical wall clips at the declared host surface without cutting the host shell or disappearing in plan view. +- A mounted roof attachment survives scene and level cloning with its host segment reference remapped. +- Removing or moving it restores/recomputes the sibling roof. +- Focused unit tests, package type checks, and interactive 2D/3D placement checks must pass before release. + +## Follow-up profiles + +Introduce an explicit radial-profile discriminator only when the corresponding geometry ships: `bell`, `ogee`, `onion`, and `dome`. Each profile gets validated controls appropriate to its curve and can share the conical placement, material, and CSG infrastructure. diff --git a/wiki/conical-turret-roof-research.md b/wiki/conical-turret-roof-research.md new file mode 100644 index 0000000000..e52263c14e --- /dev/null +++ b/wiki/conical-turret-roof-research.md @@ -0,0 +1,119 @@ +# Circular tower-cap roof research + +## Recommendation + +Call the first feature **Conical roof** in the UI and `conical` in the roof-type schema. "Turret roof" says where the roof is used, not what shape it has. "Pepperpot" and "candle-snuffer" are picturesque aliases, but official records apply both names to more than one profile. + +The first implementation should be a regular cone on a circular plan. Its editable variations belong to one feature: support diameter, pitch, eave overhang, roof build-up, circular eave trim, material, and optional finial. A steep cone is still a cone. Do not add `pepperpot` or `turret` as separate shape values. + +Bell-cast, ogee, onion, and domed caps are established forms, but they are not pitch settings on a cone. They need curved radial profiles and different controls. Add them later as explicit, discriminated roof profiles. They may share a private surface-of-revolution geometry helper and the same placement/trimming system, but should not be hidden behind a free-form "curvature" slider. + +## What the requested roof is called + +The Getty Art & Architecture Thesaurus defines a [conical roof](https://www.getty.edu/vow/AATFullDisplay?find=drypoint&logic=AND¬e=&subjectid=300411464) as a roof circular in plan that rises to a point as a regular cone. Historic England uses the same term for real tower caps, including a [cylindrical stair turret with a conical roof](https://historicengland.org.uk/listing/the-list/list-entry/1263108) and a [circular stair turret rising from an aisle roof](https://historicengland.org.uk/listing/the-list/list-entry/1191273). + +That gives the product a precise name: + +- UI item: `Conical roof` +- Schema value: `conical` +- Descriptive copy: `A circular roof that rises to a single point, commonly used on towers and turrets.` + +"Conical turret roof" is useful prose when the host matters. It is redundant as the stored shape name. + +## Established circular tower-cap forms + +| Form | Architectural distinction | Product treatment | +|---|---|---| +| Conical roof | Circular plan, straight radial profile, one apex. Getty calls the full shape a regular cone. | Implement now as `conical`. Pitch or rise changes do not create a new type. | +| Bell roof or bell-cast roof | A curved or flared cap whose lower edge opens outward like a bell. The term is plan-independent: Historic England records both [bell-cast roofs on stair towers](https://historicengland.org.uk/listing/the-list/list-entry/1388072) and a [bell-cast pyramidal tower roof](https://historicengland.org.uk/listing/the-list/list-entry/1344559). "Bell-cast" can also describe only the eave flare on an otherwise different roof. | Future explicit profile. Do not model it as cone pitch. Store its flare or control points in a profile-specific object. | +| Ogee roof or ogee dome | Its section uses an ogee, a continuous double curve. Getty's architectural glossary describes an [ogee as a convex and concave S-curve](https://www.getty.edu/publications/resources/virtuallibrary/9780892369812.pdf). Historic England identifies a [ribbed ogee dome on a circular bay](https://historicengland.org.uk/listing/the-list/list-entry/1474405) and an [ogee roof forming a corner turret](https://historicengland.org.uk/listing/the-list/list-entry/1265525). | Future explicit profile. It needs at least an inflection position and upper/lower bulge controls, or a fixed preset with height and flare controls. | +| Ogival roof | Historic descriptions sometimes use "ogivally arched" for a pointed curved cap, as in this [observatory roof with an onion dome above](https://historicengland.org.uk/listing/the-list/list-entry/1201700). Usage is less consistent than `ogee`. | Use `ogee` in the UI. Keep `ogival` as a search alias, not a separate enum value. | +| Onion dome | A pointed bulbous dome. Getty specifies that it is wider than its supporting drum and normally taller than it is wide in the [AAT onion-dome record](https://www.getty.edu/vow/AATFullDisplay?find=nanny&logic=OR¬e=&subjectid=300001285). Historic England records tower onion domes with lanterns and finials, including [Leicester Hebrew Congregation](https://historicengland.org.uk/listing/the-list/list-entry/1389696). | Future explicit profile. Its maximum radius can exceed the host radius, so it needs bulge position and bulge ratio rather than cone pitch. | +| Dome | A spherical or spherical-section roof over a circular, elliptical, or polygonal base in the [Getty AAT definition](https://www.getty.edu/vow/AATFullDisplay?find=&logic=¬e=&subjectid=300001280). Historic England records a [circular lock-up with a domed stone roof](https://historicengland.org.uk/listing/the-list/list-entry/1016741) and a [shallow domed roof on a circular Martello tower](https://historicengland.org.uk/listing/the-list/list-entry/1061124). | Future explicit profile. A dome needs rise or sphere-radius controls and may be shallow, hemispherical, or raised. It should not inherit cone pitch semantics. | +| Cupola | Getty describes a [cupola](https://www.getty.edu/vow/AATFullDisplay?find=Wood&logic=AND¬e=&subjectid=300002230) as a small dome or bulb that crowns a turret, roof, or larger dome. It may sit on pillars or a lantern. This is an appendage or scale/use term, not one outline. | Model later as a hosted roof accessory or small roof assembly. Do not use `cupola` as a conical profile. | +| Pepperpot | Historic England uses "pepperpot" for [lead-domed turrets](https://historicengland.org.uk/listing/the-list/list-entry/1238078), an [ogee slated pepperpot roof](https://historicengland.org.uk/listing/the-list/list-entry/1231513), and a [low stair-turret roof](https://historicengland.org.uk/listing/the-list/list-entry/1303073). It therefore does not identify one geometry. | Search alias and descriptive tag only. Never a roof-type value. | +| Candle-snuffer | Official records use the nickname for a [conical candle-snuffer roof](https://npgallery.nps.gov/GetAsset/d6b818ed-7345-477e-b94a-9aeb58ea6cf5) and for an [ogee-profile candle-snuffer](https://npgallery.nps.gov/GetAsset/5dbd5b06-b23d-44d3-9fda-032eae107a23). | Search alias only. It is no more precise than `pepperpot`. | +| Pyramidal or tented cap | Straight roof faces rise over a square or polygonal plan. It can look similar at a distance but is not circular. | Keep in the hip/pyramidal roof family. A faceted polygonal cap must not silently result from lowering the cone mesh resolution. | + +This list deliberately excludes mansard tower roofs, Rhenish helms, broach spires, and castellated flat tops. They are real tower terminations, but they are not circular-profile caps and do not belong in this feature. + +## Scope of the first feature + +### Geometry + +A regular cone has one architectural profile. In a vertical section, radius decreases linearly from the eave to zero at the apex. The necessary design controls are: + +- circular support diameter or radius; +- pitch, with rise derived from the radius using the same pitch convention as other Pascal roof segments; +- sloped eave overhang; +- wall height or placement elevation; +- deck thickness and covering thickness; +- circular fascia/eave-trim dimensions; +- material assignments for roof surface, edge trim, and wall/body; +- optional finial preset and size. + +The editor should expose pitch, not pitch and rise as two independent stored values. One must derive from the other. The optional finial does not change the roof profile. Historic England records a [circular stair turret with a conical roof and metal finial](https://historicengland.org.uk/listing/the-list/list-entry/1267310), and the National Park Service records a [round turret whose cone terminates in a circular finial](https://npgallery.nps.gov/GetAsset/0bc997ab-a2ca-46b0-9944-23f77ec0002c/). + +Mesh tessellation is a renderer detail, not an architectural setting. If a later design needs an octagonal or twelve-sided cap, that is a polygonal/tented profile with an intentional side count. It is not a low-resolution cone. + +### Fit with the current roof model + +Pascal's [`RoofSegmentNode`](../packages/core/src/schema/nodes/roof-segment.ts) already stores roof type, footprint dimensions, pitch, wall height, deck and covering thickness, overhang, trim, surface materials, and hosted accessories. A cone belongs in that roof family rather than in a new top-level node family. + +There is one important mismatch. A true conical roof has one plan diameter, while the current segment has independent `width` and `depth`. For `conical`, every creation and resize path should maintain `width === depth`, or the schema should add one canonical diameter field. Using `min(width, depth)` or allowing an ellipse would violate the architectural definition and produce surprising resize behavior. + +The existing rectangular `left`, `right`, `front`, and `back` trim distances also cannot describe a round cut. Conical placement needs a circular footprint/cutter, not an approximation made from those four fields. + +## Placement, cutting, and trim behavior + +There are two placement cases and they should remain distinct. + +### Cap on a circular tower or wall + +Snap the cone center to the circular wall center and derive or copy the support diameter. The cone remains intact. Its underside seats on the wall-top ring, and its eave projects beyond that ring by the configured overhang. + +If the wall is independently resizable, define whether the roof follows the wall diameter or keeps a manual diameter. The default should follow the host. A detached/manual mode can keep the roof independent. + +### Turret passing through an existing roof + +When a round tower rises through a larger roof, the tower body is the cutter. Subtract its vertical circular envelope from every intersected sibling roof deck and covering, then union or compose the tower and cap as visible solids. Do not trim the cone against the host roof as the normal case. If the cap intersects the larger roof, the tower is too short or the placement is invalid. + +The cut and flashing must update when the tower moves, its diameter changes, or the host roof changes pitch. The placement system should handle: + +- a circle crossing one or several host roof faces; +- intersections at hips, valleys, and ridges; +- partial circles near an eave or roof boundary; +- deterministic ownership when several roof segments overlap; +- removal of the cut when the turret or cap is deleted; +- selection and movement in both floorplan and 3D views. + +Generate an intersection loop from the circular tower wall against the actual roof surface. Use it for the cut boundary and a later flashing/skirt mesh. A rectangular bounding-box cut will remove too much roof at diagonal slopes. + +The National Park Service's roofing guidance treats the roof as a weathering membrane and calls out careful flashing around a steep conical cupola to promote runoff in [Preservation Brief 4](https://www.nps.gov/orgs/1739/upload/preservation-brief-04-roofing.pdf). That supports modeling the junction as part of placement rather than leaving two intersecting meshes. The same source shows a finial, ribbing, and a lead-coated copper covering, but those are finish/accessory choices, not new roof profiles. + +## Suggested delivery boundary + +Ship the first version with: + +1. `conical` as one new roof type; +2. a locked circular footprint; +3. pitch, diameter, overhang, wall height, thickness, materials, circular eave trim, and optional finial; +4. 2D and 3D placement and resize parity; +5. a round cutter for sibling roof geometry when the tower passes through another roof; +6. placement validation that prevents the cap itself from intersecting the host roof. + +Defer: + +- bell-cast and ogee profiles; +- onion domes; +- spherical and shallow domes; +- lanterns and multi-stage cupolas; +- polygonal/tented caps; +- free-form profile editing; +- structural framing and code claims. + +When curved variants arrive, prefer explicit profile records such as `cone`, `bell`, `ogee`, `onion`, and `dome`, each with its own validated controls. They can live under the same roof-segment node and share placement, materials, cutting, and selection behavior. Separate top-level node types would duplicate the hard parts without adding useful domain meaning. + +## Source-quality note + +Getty AAT supplies controlled architectural definitions. Historic England and National Park Service records show how heritage professionals apply the terms to actual towers and roof details. The product recommendations about schema shape, cutting, and delivery order are engineering conclusions drawn from those definitions and the current Pascal model.