diff --git a/.artifacts/browser-canopy/butterfly-isolated-after-seam.png b/.artifacts/browser-canopy/butterfly-isolated-after-seam.png new file mode 100644 index 0000000000..68c4a661c7 Binary files /dev/null and b/.artifacts/browser-canopy/butterfly-isolated-after-seam.png differ diff --git a/.artifacts/browser-canopy/butterfly-rebuilt-after-seam-wait.png b/.artifacts/browser-canopy/butterfly-rebuilt-after-seam-wait.png new file mode 100644 index 0000000000..ef0a9266b6 Binary files /dev/null and b/.artifacts/browser-canopy/butterfly-rebuilt-after-seam-wait.png differ diff --git a/.artifacts/browser-canopy/butterfly-rebuilt-after-seam.png b/.artifacts/browser-canopy/butterfly-rebuilt-after-seam.png new file mode 100644 index 0000000000..d54bc1c381 Binary files /dev/null and b/.artifacts/browser-canopy/butterfly-rebuilt-after-seam.png differ diff --git a/.artifacts/browser-canopy/mono-j-correct-3d-close.png b/.artifacts/browser-canopy/mono-j-correct-3d-close.png new file mode 100644 index 0000000000..bddc1b1d3c Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-correct-3d-close.png differ diff --git a/.artifacts/browser-canopy/mono-j-correct-3d-final.png b/.artifacts/browser-canopy/mono-j-correct-3d-final.png new file mode 100644 index 0000000000..b66b06964a Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-correct-3d-final.png differ diff --git a/.artifacts/browser-canopy/mono-j-correct-3d.png b/.artifacts/browser-canopy/mono-j-correct-3d.png new file mode 100644 index 0000000000..56d080af00 Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-correct-3d.png differ diff --git a/.artifacts/browser-canopy/mono-j-correct-top.png b/.artifacts/browser-canopy/mono-j-correct-top.png new file mode 100644 index 0000000000..2402469bc1 Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-correct-top.png differ diff --git a/.artifacts/browser-canopy/mono-j-fixed.png b/.artifacts/browser-canopy/mono-j-fixed.png new file mode 100644 index 0000000000..ee2f05b361 Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-fixed.png differ diff --git a/.artifacts/browser-canopy/mono-j-no-internal-faces-wait.png b/.artifacts/browser-canopy/mono-j-no-internal-faces-wait.png new file mode 100644 index 0000000000..a84243b929 Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-no-internal-faces-wait.png differ diff --git a/.artifacts/browser-canopy/mono-j-no-internal-faces.png b/.artifacts/browser-canopy/mono-j-no-internal-faces.png new file mode 100644 index 0000000000..d1efac31f5 Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-no-internal-faces.png differ diff --git a/.artifacts/browser-canopy/mono-j-rebuilt-after-seam.png b/.artifacts/browser-canopy/mono-j-rebuilt-after-seam.png new file mode 100644 index 0000000000..8ad02b90b3 Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-rebuilt-after-seam.png differ diff --git a/.artifacts/browser-canopy/mono-j-stack-final-wait.png b/.artifacts/browser-canopy/mono-j-stack-final-wait.png new file mode 100644 index 0000000000..d975ad18a2 Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-stack-final-wait.png differ diff --git a/.artifacts/browser-canopy/mono-j-stack-final.png b/.artifacts/browser-canopy/mono-j-stack-final.png new file mode 100644 index 0000000000..714173547e Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-stack-final.png differ diff --git a/.artifacts/browser-canopy/mono-j-transition-closure.png b/.artifacts/browser-canopy/mono-j-transition-closure.png new file mode 100644 index 0000000000..e3b875eb14 Binary files /dev/null and b/.artifacts/browser-canopy/mono-j-transition-closure.png differ diff --git a/.artifacts/browser-canopy/mono-l-fixed.png b/.artifacts/browser-canopy/mono-l-fixed.png new file mode 100644 index 0000000000..cb353c766c Binary files /dev/null and b/.artifacts/browser-canopy/mono-l-fixed.png differ diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 5ba049ddbe..885d59eb39 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,6 +1,15 @@ 'use client' -import { nodeRegistry } from '@pascal-app/core' +import { + type AnyNodeId, + createSceneApi, + isCurvedWall, + nodeRegistry, + type RoofType, + RoofType as RoofTypeSchema, + useRegistryVersion, + useScene, +} from '@pascal-app/core' import { type FloorplanMode, getFloorplanNodeExtension, @@ -11,15 +20,23 @@ import { useEditor, useFloorplanMode, } from '@pascal-app/editor' -import { useLiquidLineToolOptions } from '@pascal-app/nodes' +import { createConicalRoofSectorAboveWall, useLiquidLineToolOptions } from '@pascal-app/nodes' +import { useViewer } from '@pascal-app/viewer' 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, + getRoofFootprintSource, + getRoofFootprintSources, + ROOF_TYPE_OPTIONS, + type RoofFootprintSource, +} from '@/lib/build-tab-state' import { cn } from '@/lib/utils' /** @@ -169,10 +186,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 +224,29 @@ 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) +} + +function activateRoofType(roofType: RoofType): void { + const editor = useEditor.getState() + if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') + const footprintSource = getRoofFootprintSource( + roofType, + editor.toolDefaults.roof?.footprintSource, + ) + editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, roofType, footprintSource }) +} + +function activateRoofFootprintSource(footprintSource: RoofFootprintSource): void { + const editor = useEditor.getState() + if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') + editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, footprintSource }) } /** @@ -208,18 +267,26 @@ const MEP_TOOL_KINDS = new Set([ export function BuildTab() { const activeTool = useEditor((s) => s.tool) const mode = useEditor((s) => s.mode) + const roofDefaults = useEditor((s) => s.toolDefaults.roof) + const selectedId = useViewer((s) => + s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined, + ) + const currentLevelId = useViewer((s) => s.selection.levelId) + const setSelection = useViewer((s) => s.setSelection) + const selectedCurvedWall = useScene((s) => { + const node = selectedId ? s.nodes[selectedId as AnyNodeId] : undefined + return node?.type === 'wall' && isCurvedWall(node) ? node : null + }) 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 +309,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,9 +317,55 @@ 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 parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType) + const activeRoofType = parsedRoofType.success ? parsedRoofType.data : 'gable' + const footprintSources = getRoofFootprintSources(activeRoofType) + const activeFootprintSource = getRoofFootprintSource( + activeRoofType, + roofDefaults?.footprintSource, + ) + + const conicalSourceRef = useRef(null) + useEffect(() => { + if ( + !( + mode === 'build' && + activeTool === 'roof' && + activeRoofType === 'conical' && + activeFootprintSource === 'walls' && + currentLevelId + ) + ) { + conicalSourceRef.current = null + return + } + if (!selectedCurvedWall) { + conicalSourceRef.current = null + return + } + if (conicalSourceRef.current === selectedCurvedWall.id) return + + const segmentId = createConicalRoofSectorAboveWall( + selectedCurvedWall, + useScene.getState().nodes, + createSceneApi(useScene), + currentLevelId, + ) + if (!segmentId) return + conicalSourceRef.current = selectedCurvedWall.id + setSelection({ selectedIds: [segmentId] }) + }, [ + activeFootprintSource, + activeRoofType, + activeTool, + currentLevelId, + mode, + selectedCurvedWall, + setSelection, + ]) const isTypeActive = (type: BuildType) => { if (type.mode) return mode === type.mode @@ -364,54 +455,124 @@ export function BuildTab() {
- ) : mode === 'build' && - (activeTool === 'roof' || isRoofFeatureActive) && - roofFeatures.length > 0 ? ( -
-
- Features & extensions + ) : mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive) ? ( +
+
+
Roof type
+
+ {ROOF_TYPE_OPTIONS.map((roofType) => { + const active = activeTool === 'roof' && activeRoofType === roofType.value + return ( + + ) + })} +
- -
- {roofFeatures.map((feature) => { - const active = mode === 'build' && activeTool === feature.kind + +
+
Footprint from
+
+ {footprintSources.map((source) => { + const active = activeTool === 'roof' && activeFootprintSource === source.value return ( - - - - - - {feature.label} - - + ) })}
- +

+ {activeFootprintSource === 'room' + ? 'Hover a room to preview its boundary, then click to place.' + : activeFootprintSource === 'walls' + ? 'Select a curved wall to match its radius and arc.' + : activeRoofType === 'conical' + ? 'Draw the cone diameter with two clicks.' + : 'Draw the roof footprint with two corner clicks.'} +

+
+ + {roofFeatures.length > 0 ? ( +
+
+ Features & extensions +
+ +
+ {roofFeatures.map((feature) => { + const active = mode === 'build' && feature.id === activeRoofFeatureId + return ( + + + + + + {feature.label} + + + ) + })} +
+
+
+ ) : null}
) : isMepActive ? (
diff --git a/apps/editor/lib/build-tab-state.test.ts b/apps/editor/lib/build-tab-state.test.ts new file mode 100644 index 0000000000..5c114ace83 --- /dev/null +++ b/apps/editor/lib/build-tab-state.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test' +import { + getActiveRoofFeatureId, + getRoofFootprintSource, + getRoofFootprintSources, + ROOF_TYPE_OPTIONS, + type RoofFeatureIdentity, +} from './build-tab-state' + +const FEATURES: RoofFeatureIdentity[] = [ + { id: 'lean-to-extension', kind: 'lean-to-extension' }, + { id: 'skylight', kind: 'skylight' }, +] + +describe('roof feature selection', () => { + test('does not select every accessory for the plain roof tool', () => { + expect(getActiveRoofFeatureId(FEATURES, 'roof')).toBeNull() + }) + + test('selects exactly the matching accessory', () => { + expect(getActiveRoofFeatureId(FEATURES, 'lean-to-extension')).toBe('lean-to-extension') + }) + + test('ignores missing tool identities', () => { + const malformed = FEATURES.map(({ id }) => ({ id })) + expect(getActiveRoofFeatureId(malformed, undefined)).toBeNull() + expect(getActiveRoofFeatureId(malformed, 'skylight')).toBeNull() + }) +}) + +test('roof creation exposes every supported roof type', () => { + expect(ROOF_TYPE_OPTIONS.map((option) => option.value)).toEqual([ + 'hip', + 'gable', + 'shed', + 'flat', + 'gambrel', + 'dutch', + 'mansard', + 'conical', + ]) +}) + +test('conical roofs expose wall and draw footprint sources', () => { + expect(getRoofFootprintSources('conical').map((source) => source.value)).toEqual([ + 'walls', + 'draw', + ]) + expect(getRoofFootprintSource('conical', 'room')).toBe('walls') + expect(getRoofFootprintSource('conical', 'draw')).toBe('draw') +}) + +test('non-conical roofs expose room and draw footprint sources', () => { + expect(getRoofFootprintSources('hip').map((source) => source.value)).toEqual(['room', 'draw']) + expect(getRoofFootprintSource('hip', 'walls')).toBe('room') + expect(getRoofFootprintSource('hip', 'draw')).toBe('draw') +}) diff --git a/apps/editor/lib/build-tab-state.ts b/apps/editor/lib/build-tab-state.ts new file mode 100644 index 0000000000..7c45a2c44d --- /dev/null +++ b/apps/editor/lib/build-tab-state.ts @@ -0,0 +1,54 @@ +import type { RoofType } from '@pascal-app/core' + +export type RoofFeatureIdentity = { + id: string + kind?: string +} + +const ROOF_FOOTPRINT_SOURCES = [ + { label: 'Room', value: 'room' }, + { label: 'Wall', value: 'walls' }, + { label: 'Draw', value: 'draw' }, +] as const + +export type RoofFootprintSource = (typeof ROOF_FOOTPRINT_SOURCES)[number]['value'] + +const CONICAL_ROOF_FOOTPRINT_SOURCES = [ + ROOF_FOOTPRINT_SOURCES[1], + ROOF_FOOTPRINT_SOURCES[2], +] as const + +const STANDARD_ROOF_FOOTPRINT_SOURCES = [ + ROOF_FOOTPRINT_SOURCES[0], + ROOF_FOOTPRINT_SOURCES[2], +] as const + +export function getRoofFootprintSources(roofType: RoofType) { + return roofType === 'conical' ? CONICAL_ROOF_FOOTPRINT_SOURCES : STANDARD_ROOF_FOOTPRINT_SOURCES +} + +export function getRoofFootprintSource(roofType: RoofType, value: unknown): RoofFootprintSource { + const sources = getRoofFootprintSources(roofType) + return sources.some((source) => source.value === value) + ? (value as RoofFootprintSource) + : sources[0].value +} + +export const ROOF_TYPE_OPTIONS: ReadonlyArray<{ label: string; value: RoofType }> = [ + { label: 'Hip', value: 'hip' }, + { label: 'Gable', value: 'gable' }, + { label: 'Shed', value: 'shed' }, + { label: 'Flat', value: 'flat' }, + { label: 'Gambrel', value: 'gambrel' }, + { label: 'Dutch', value: 'dutch' }, + { label: 'Mansard', value: 'mansard' }, + { label: 'Conical', value: 'conical' }, +] + +export function getActiveRoofFeatureId( + features: readonly RoofFeatureIdentity[], + activeTool: string | null | undefined, +): string | null { + if (!activeTool) return null + return features.find((feature) => feature.kind === activeTool)?.id ?? null +} diff --git a/package.json b/package.json index e199acdbe2..56f12089ba 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "format": "biome format --write", "format:check": "biome format", "check": "biome check", + "checks": "bun run check && bun run check-types", "check:fix": "biome check --write", "check-types": "turbo run check-types", "test": "turbo run test", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5d2720fa12..147a11e6d7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -94,6 +94,15 @@ export { } from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' +export { + type ConicalRoofInvalidPlacement, + type ConicalRoofLevelPlacement, + type ConicalRoofPlacement, + type ConicalRoofSurfacePlacement, + type ResolveConicalRoofPlacementInput, + type RoofPlacementMode, + resolveConicalRoofPlacement, +} from './lib/conical-roof-placement' export { clampDoorOperationState, getDoorRenderOpenAmount, @@ -144,6 +153,7 @@ export { type RoofPlanSegment, roofOverlapEntryOwns, roofPlanBoundsOverlap, + roofPlanOverlapEntryOwns, } from './lib/roof-overlap' export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' export { diff --git a/packages/core/src/lib/conical-roof-placement.test.ts b/packages/core/src/lib/conical-roof-placement.test.ts new file mode 100644 index 0000000000..35abb61edf --- /dev/null +++ b/packages/core/src/lib/conical-roof-placement.test.ts @@ -0,0 +1,138 @@ +// @ts-expect-error - bun:test is provided by the Bun runtime; core does not depend on @types/bun. +import { describe, expect, test } from 'bun:test' +import { LevelNode, RoofNode, RoofSegmentNode } from '../schema' +import { resolveConicalRoofPlacement } from './conical-roof-placement' + +function sceneWithHost() { + const level = LevelNode.parse({ + id: 'level_host', + children: ['roof_host'], + }) + const roof = RoofNode.parse({ + id: 'roof_host', + parentId: level.id, + position: [2, 1, 3], + children: ['rseg_host'], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_host', + parentId: roof.id, + roofType: 'gable', + width: 10, + depth: 8, + wallHeight: 2, + pitch: 45, + }) + return { + level, + roof, + segment, + nodes: { + [level.id]: level, + [roof.id]: roof, + [segment.id]: segment, + }, + } +} + +describe('conical roof placement', () => { + test('ground mode creates a level-supported roof at the drawn center', () => { + const { level, nodes } = sceneWithHost() + const placement = resolveConicalRoofPlacement({ + nodes, + levelId: level.id, + center: [2, 3], + radius: 1, + curbHeight: 0.5, + mode: 'ground', + }) + + expect(placement).toEqual({ + valid: true, + kind: 'level', + position: [2, 0, 3], + wallHeight: 0.5, + support: { kind: 'level' }, + }) + }) + + test('auto mode mounts a fully contained circle on the highest roof surface', () => { + const { level, roof, segment, nodes } = sceneWithHost() + const placement = resolveConicalRoofPlacement({ + nodes, + levelId: level.id, + center: [2, 3], + radius: 1, + curbHeight: 0.5, + mode: 'auto', + }) + + expect(placement.valid).toBe(true) + if (!(placement.valid && placement.kind === 'roof')) throw new Error('expected roof placement') + expect(placement.hostRoofId).toBe(roof.id) + expect(placement.position[0]).toBe(2) + expect(placement.position[2]).toBe(3) + expect(placement.wallHeight).toBeGreaterThan(0.5) + expect(placement.support).toEqual({ + kind: 'roof', + roofSegmentId: segment.id, + localPosition: [0, 0], + curbHeight: 0.5, + }) + }) + + test('roof mode rejects a circle that has no complete roof support', () => { + const { level, nodes } = sceneWithHost() + const placement = resolveConicalRoofPlacement({ + nodes, + levelId: level.id, + center: [20, 20], + radius: 1, + curbHeight: 0.5, + mode: 'roof', + }) + + expect(placement).toEqual({ valid: false, reason: 'no-roof-support' }) + }) + + test('auto mode falls back to the level when no roof supports the circle', () => { + const { level, nodes } = sceneWithHost() + const placement = resolveConicalRoofPlacement({ + nodes, + levelId: level.id, + center: [20, 20], + radius: 1, + curbHeight: 0.75, + mode: 'auto', + }) + + expect(placement).toEqual({ + valid: true, + kind: 'level', + position: [20, 0, 20], + wallHeight: 0.75, + support: { kind: 'level' }, + }) + }) + + test('roof schema preserves the optional surface attachment and parses legacy roofs', () => { + const legacy = RoofNode.parse({ id: 'roof_legacy' }) + expect(legacy.support).toEqual({ kind: 'level' }) + + const mounted = RoofNode.parse({ + id: 'roof_mounted', + support: { + kind: 'roof', + roofSegmentId: 'rseg_host', + localPosition: [1.25, -0.5], + curbHeight: 0.4, + }, + }) + expect(mounted.support).toEqual({ + kind: 'roof', + roofSegmentId: 'rseg_host', + localPosition: [1.25, -0.5], + curbHeight: 0.4, + }) + }) +}) diff --git a/packages/core/src/lib/conical-roof-placement.ts b/packages/core/src/lib/conical-roof-placement.ts new file mode 100644 index 0000000000..56b11ae49b --- /dev/null +++ b/packages/core/src/lib/conical-roof-placement.ts @@ -0,0 +1,210 @@ +import type { AnyNode, LevelNode, RoofNode, RoofSegmentNode, RoofSupport } from '../schema' +import { getRoofSegmentSurfaceY } from '../schema' + +export type RoofPlacementMode = 'auto' | 'ground' | 'roof' + +export type ConicalRoofLevelPlacement = { + valid: true + kind: 'level' + position: [number, number, number] + wallHeight: number + support: Extract +} + +export type ConicalRoofSurfacePlacement = { + valid: true + kind: 'roof' + position: [number, number, number] + wallHeight: number + hostRoofId: RoofNode['id'] + support: Extract +} + +export type ConicalRoofInvalidPlacement = { + valid: false + reason: 'no-roof-support' +} + +export type ConicalRoofPlacement = + | ConicalRoofLevelPlacement + | ConicalRoofSurfacePlacement + | ConicalRoofInvalidPlacement + +export type ResolveConicalRoofPlacementInput = { + nodes: Readonly> + levelId: LevelNode['id'] + center: readonly [number, number] + radius: number + curbHeight: number + mode: RoofPlacementMode +} + +const CUTTER_SEAT_DEPTH = 0.1 +const FOOTPRINT_EPSILON = 1e-6 +const CIRCLE_SEGMENTS = 32 +const HEIGHT_GRID_STEPS = 8 + +type RoofCandidate = { + roof: RoofNode + segment: RoofSegmentNode + localCenter: [number, number] + minSurfaceY: number + maxSurfaceY: number +} + +function inverseRotatePlan(x: number, z: number, rotation: number): [number, number] { + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [x * cos - z * sin, x * sin + z * cos] +} + +function worldToSegmentPlan( + roof: RoofNode, + segment: RoofSegmentNode, + point: readonly [number, number], +): [number, number] { + const [roofX, roofZ] = inverseRotatePlan( + point[0] - roof.position[0], + point[1] - roof.position[2], + roof.rotation ?? 0, + ) + return inverseRotatePlan( + roofX - segment.position[0], + roofZ - segment.position[2], + segment.rotation ?? 0, + ) +} + +function pointIsInsideSegment(segment: RoofSegmentNode, point: readonly [number, number]): boolean { + if (segment.roofType === 'conical') { + return Math.hypot(point[0], point[1]) <= segment.width / 2 + FOOTPRINT_EPSILON + } + return ( + Math.abs(point[0]) <= segment.width / 2 + FOOTPRINT_EPSILON && + Math.abs(point[1]) <= segment.depth / 2 + FOOTPRINT_EPSILON + ) +} + +function circleBoundary(center: readonly [number, number], radius: number): [number, number][] { + if (radius <= FOOTPRINT_EPSILON) return [[center[0], center[1]]] + return Array.from({ length: CIRCLE_SEGMENTS }, (_, index) => { + const angle = (index / CIRCLE_SEGMENTS) * Math.PI * 2 + return [center[0] + Math.cos(angle) * radius, center[1] + Math.sin(angle) * radius] + }) +} + +function circleHeightSamples( + center: readonly [number, number], + radius: number, +): [number, number][] { + const samples = circleBoundary(center, radius) + if (radius <= FOOTPRINT_EPSILON) return samples + for (let zIndex = 0; zIndex <= HEIGHT_GRID_STEPS; zIndex += 1) { + const z = -radius + (zIndex / HEIGHT_GRID_STEPS) * radius * 2 + for (let xIndex = 0; xIndex <= HEIGHT_GRID_STEPS; xIndex += 1) { + const x = -radius + (xIndex / HEIGHT_GRID_STEPS) * radius * 2 + if (x * x + z * z > radius * radius + FOOTPRINT_EPSILON) continue + samples.push([center[0] + x, center[1] + z]) + } + } + return samples +} + +function findRoofCandidate( + nodes: Readonly>, + levelId: LevelNode['id'], + center: readonly [number, number], + radius: number, +): RoofCandidate | null { + const boundary = circleBoundary(center, radius) + const heightSamples = circleHeightSamples(center, radius) + let best: RoofCandidate | null = null + + for (const node of Object.values(nodes)) { + if (node.type !== 'roof' || node.parentId !== levelId) continue + const roof = node + for (const segmentId of roof.children ?? []) { + const child = nodes[segmentId] + if (child?.type !== 'roof-segment') continue + const segment = child + if ( + !boundary.every((point) => + pointIsInsideSegment(segment, worldToSegmentPlan(roof, segment, point)), + ) + ) { + continue + } + + let minSurfaceY = Number.POSITIVE_INFINITY + let maxSurfaceY = Number.NEGATIVE_INFINITY + for (const point of heightSamples) { + const local = worldToSegmentPlan(roof, segment, point) + const surfaceY = + roof.position[1] + + segment.position[1] + + getRoofSegmentSurfaceY(segment, local[0], local[1]) + minSurfaceY = Math.min(minSurfaceY, surfaceY) + maxSurfaceY = Math.max(maxSurfaceY, surfaceY) + } + + if (!(Number.isFinite(minSurfaceY) && Number.isFinite(maxSurfaceY))) continue + if (best && best.maxSurfaceY >= maxSurfaceY) continue + best = { + roof, + segment, + localCenter: worldToSegmentPlan(roof, segment, center), + minSurfaceY, + maxSurfaceY, + } + } + } + + return best +} + +function levelPlacement( + center: readonly [number, number], + curbHeight: number, +): ConicalRoofLevelPlacement { + return { + valid: true, + kind: 'level', + position: [center[0], 0, center[1]], + wallHeight: Math.max(0, curbHeight), + support: { kind: 'level' }, + } +} + +export function resolveConicalRoofPlacement({ + nodes, + levelId, + center, + radius, + curbHeight, + mode, +}: ResolveConicalRoofPlacementInput): ConicalRoofPlacement { + if (mode === 'ground') return levelPlacement(center, curbHeight) + + const candidate = findRoofCandidate(nodes, levelId, center, Math.max(0, radius)) + if (!candidate) { + return mode === 'roof' + ? { valid: false, reason: 'no-roof-support' } + : levelPlacement(center, curbHeight) + } + + const safeCurbHeight = Math.max(0, curbHeight) + const baseY = candidate.minSurfaceY - CUTTER_SEAT_DEPTH + return { + valid: true, + kind: 'roof', + position: [center[0], baseY, center[1]], + wallHeight: candidate.maxSurfaceY - baseY + safeCurbHeight, + hostRoofId: candidate.roof.id, + support: { + kind: 'roof', + roofSegmentId: candidate.segment.id, + localPosition: candidate.localCenter, + curbHeight: safeCurbHeight, + }, + } +} diff --git a/packages/core/src/lib/polygon-union.test.ts b/packages/core/src/lib/polygon-union.test.ts index d0c872eef4..3b1a5dc785 100644 --- a/packages/core/src/lib/polygon-union.test.ts +++ b/packages/core/src/lib/polygon-union.test.ts @@ -13,6 +13,17 @@ function polygonArea(points: Point2D[]) { return Math.abs(area / 2) } +function hasRepeatedNonAdjacentPoint(points: Point2D[]) { + return points.some((point, index) => + points.some( + (candidate, candidateIndex) => + candidateIndex > index + 1 && + !(index === 0 && candidateIndex === points.length - 1) && + Math.hypot(point[0] - candidate[0], point[1] - candidate[1]) <= 1e-7, + ), + ) +} + describe('unionPolygons', () => { test('collapses a contained polygon into the containing polygon', () => { const small: Point2D[] = [ @@ -74,6 +85,87 @@ describe('unionPolygons', () => { expect(result).toHaveLength(2) expect(result.map(polygonArea)).toEqual([1, 1]) }) + + test('does not stitch point-touching branches into a self-touching ring', () => { + const lowerTip: Point2D[] = [ + [-4.4073, -1.383], + [-4.2663, -1.383], + [-4.4073, -1.22435], + ] + const upperTip: Point2D[] = [ + [-2.1038, 0.35056], + [-0.9401, 1.385], + [-3.0233, 1.385], + ] + const connectingBand: Point2D[] = [ + [-4.2663, -1.383], + [-3.0233, 1.385], + [-4.4073, 1.385], + [-4.4073, -1.22435], + ] + + const result = unionPolygons([lowerTip, upperTip, connectingBand]) + + expect(result).toHaveLength(2) + expect(result.some(hasRepeatedNonAdjacentPoint)).toBe(false) + }) + + test('keeps point-touching branches separate in every orientation and input order', () => { + const polygons: Point2D[][] = [ + [ + [-4.4073, -1.383], + [-4.2663, -1.383], + [-4.4073, -1.22435], + ], + [ + [-2.1038, 0.35056], + [-0.9401, 1.385], + [-3.0233, 1.385], + ], + [ + [-4.2663, -1.383], + [-3.0233, 1.385], + [-4.4073, 1.385], + [-4.4073, -1.22435], + ], + ] + const orders = [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ] + const expectedArea = polygons.reduce((sum, polygon) => sum + polygonArea(polygon), 0) + + for (const order of orders) { + for (let quarterTurns = 0; quarterTurns < 4; quarterTurns++) { + for (const reflection of [-1, 1]) { + const transformed = order.map((index) => + polygons[index]!.map(([sourceX, sourceZ]): Point2D => { + let x = sourceX * reflection + let z = sourceZ + for (let turn = 0; turn < quarterTurns; turn++) { + const previousX = x + x = -z + z = previousX + } + return [x, z] + }), + ) + + const result = unionPolygons(transformed) + + expect(result).toHaveLength(2) + expect(result.some(hasRepeatedNonAdjacentPoint)).toBe(false) + expect(result.reduce((sum, polygon) => sum + polygonArea(polygon), 0)).toBeCloseTo( + expectedArea, + ) + } + } + } + }) }) describe('subtractPolygonsFromPolygon', () => { diff --git a/packages/core/src/lib/polygon-union.ts b/packages/core/src/lib/polygon-union.ts index ab2ae67755..3889c6ef56 100644 --- a/packages/core/src/lib/polygon-union.ts +++ b/packages/core/src/lib/polygon-union.ts @@ -261,6 +261,26 @@ function assembleRings(segments: Segment[]) { const rings: Point2D[][] = [] + const nextBoundarySegment = (ring: Point2D[], candidates: Segment[]) => { + const previous = ring[ring.length - 2]! + const current = ring[ring.length - 1]! + const incomingX = current[0] - previous[0] + const incomingZ = current[1] - previous[1] + const reverseIncomingAngle = Math.atan2(-incomingZ, -incomingX) + const clockwiseTurn = (segment: Segment) => { + const outgoingAngle = Math.atan2(segment.end[1] - current[1], segment.end[0] - current[0]) + const turn = reverseIncomingAngle - outgoingAngle + return ((turn % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) + } + + return [...candidates].sort((left, right) => { + return ( + clockwiseTurn(left) - clockwiseTurn(right) || + pointKey(left.end).localeCompare(pointKey(right.end)) + ) + })[0] + } + for (const firstSegment of segments) { if (firstSegment.used) continue @@ -270,7 +290,8 @@ function assembleRings(segments: Segment[]) { let currentKey = pointKey(firstSegment.end) while (currentKey !== startKey) { - const next = byStart.get(currentKey)?.find((segment) => !segment.used) + const candidates = byStart.get(currentKey)?.filter((segment) => !segment.used) ?? [] + const next = nextBoundarySegment(ring, candidates) if (!next) break next.used = true diff --git a/packages/core/src/lib/roof-overlap.test.ts b/packages/core/src/lib/roof-overlap.test.ts index 8fc9a814c4..254ffc7114 100644 --- a/packages/core/src/lib/roof-overlap.test.ts +++ b/packages/core/src/lib/roof-overlap.test.ts @@ -1,6 +1,11 @@ // @ts-expect-error — bun:test is provided by the Bun runtime; core does not depend on @types/bun. import { describe, expect, test } from 'bun:test' -import { getRoofPlanBounds, roofOverlapEntryOwns, roofPlanBoundsOverlap } from './roof-overlap' +import { + getRoofPlanBounds, + roofOverlapEntryOwns, + roofPlanBoundsOverlap, + roofPlanOverlapEntryOwns, +} from './roof-overlap' describe('roof overlap', () => { test('larger segments own intersections with stable ID tie-breaking', () => { @@ -12,6 +17,50 @@ describe('roof overlap', () => { expect(roofOverlapEntryOwns({ ...current, width: 3 }, current)).toBe(false) }) + test('a declared host roof clips its mounted conical roof', () => { + const host = { + roofId: 'roof_host', + segmentId: 'seg_host', + roofType: 'gable', + width: 10, + depth: 8, + } + const conical = { + roofId: 'roof_tower', + segmentId: 'seg_tower', + roofType: 'conical', + width: 3, + depth: 3, + supportRoofId: host.roofId, + supportRoofSegmentId: host.segmentId, + } + + expect(roofOverlapEntryOwns(conical, host)).toBe(false) + expect(roofOverlapEntryOwns(host, conical)).toBe(true) + expect(roofPlanOverlapEntryOwns(conical, host)).toBe(true) + expect(roofPlanOverlapEntryOwns(host, conical)).toBe(false) + }) + + test('a ground conical roof does not automatically cut a larger roof', () => { + const host = { + roofId: 'roof_host', + segmentId: 'seg_host', + roofType: 'gable', + width: 10, + depth: 8, + } + const groundConical = { + roofId: 'roof_tower', + segmentId: 'seg_tower', + roofType: 'conical', + width: 3, + depth: 3, + } + + expect(roofOverlapEntryOwns(groundConical, host)).toBe(false) + expect(roofOverlapEntryOwns(host, groundConical)).toBe(true) + }) + test('computes rotated world bounds and rejects distant roofs', () => { const bounds = getRoofPlanBounds({ position: [10, 0, 4], diff --git a/packages/core/src/lib/roof-overlap.ts b/packages/core/src/lib/roof-overlap.ts index aaec60551e..acfb27d90c 100644 --- a/packages/core/src/lib/roof-overlap.ts +++ b/packages/core/src/lib/roof-overlap.ts @@ -1,6 +1,9 @@ export type RoofOverlapEntry = { roofId: string segmentId: string + supportRoofId?: string + supportRoofSegmentId?: string + roofType?: string width: number depth: number } @@ -35,6 +38,15 @@ export function roofOverlapEntryOwns( current: RoofOverlapEntry, epsilon = 1e-6, ): boolean { + const candidateIsMountedOnCurrent = + candidate.supportRoofId === current.roofId || + candidate.supportRoofSegmentId === current.segmentId + if (candidateIsMountedOnCurrent) return false + + const currentIsMountedOnCandidate = + current.supportRoofId === candidate.roofId || + current.supportRoofSegmentId === candidate.segmentId + if (currentIsMountedOnCandidate) return true const candidateArea = candidate.width * candidate.depth const currentArea = current.width * current.depth return ( @@ -44,6 +56,24 @@ export function roofOverlapEntryOwns( ) } +export function roofPlanOverlapEntryOwns( + candidate: RoofOverlapEntry, + current: RoofOverlapEntry, + epsilon = 1e-6, +): boolean { + const candidateIsMountedOnCurrent = + candidate.supportRoofId === current.roofId || + candidate.supportRoofSegmentId === current.segmentId + if (candidateIsMountedOnCurrent) return true + + const currentIsMountedOnCandidate = + current.supportRoofId === candidate.roofId || + current.supportRoofSegmentId === candidate.segmentId + if (currentIsMountedOnCandidate) return false + + return roofOverlapEntryOwns(candidate, current, epsilon) +} + export function getRoofPlanBounds(roof: RoofPlan): RoofPlanBounds | null { if (roof.segments.length === 0) return null const roofRotation = roof.rotation ?? 0 diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 434adf4aa3..3149c35a90 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -70,6 +70,8 @@ export type EditorApi = { export type HandlePortal = 'self' | 'parent' | 'grandparent' +export type HandlePortalTarget = (node: N, sceneApi: SceneApi) => AnyNodeId | null | undefined + export type HandleAxis = 'x' | 'y' | 'z' export type HandleAnchor = 'center' | 'min' | 'max' @@ -182,6 +184,13 @@ export type LinearResizeHandle = { gridSnap?: boolean /** Kind-owned magnetic snap for the resized scalar, gated by the active snapping mode. */ magneticSnap?: (node: N, newValue: number, sceneApi: SceneApi) => number + /** + * Kind-owned structural connection snap. Unlike alignment snapping, this is + * active in every snapping mode and is bypassed only by the held Alt force + * modifier. Use it when the snapped result changes connectivity, such as two + * lean-to roof edges becoming one continuous run. + */ + connectionSnap?: (node: N, newValue: number, sceneApi: SceneApi) => number placement: HandlePlacement /** * Dimension this handle steers (e.g. `'height'`). When set, the editor @@ -198,6 +207,7 @@ export type LinearResizeHandle = { * need to ride the wall's rotation. */ portal?: HandlePortal + portalTarget?: HandlePortalTarget cursor?: Cursor /** Optional visual guide shown while the arrow is hovered or dragging. */ decoration?: HandleDecoration @@ -260,6 +270,7 @@ export type RadialResizeHandle = { max?: number | ((node: N, sceneApi: SceneApi) => number) placement: HandlePlacement portal?: HandlePortal + portalTarget?: HandlePortalTarget /** Optional visual guide shown while the arrow is hovered or dragging. */ decoration?: HandleDecoration } @@ -287,8 +298,10 @@ export type ArcResizeHandle = { /** Optional metadata for descriptors that bundle two handles per kind. */ end?: 'start' | 'end' apply: (initialNode: N, delta: number, sceneApi: SceneApi) => Partial + visible?: (node: N, sceneApi: SceneApi) => boolean placement: HandlePlacement portal?: HandlePortal + portalTarget?: HandlePortalTarget /** Optional visual guide shown while the arrow is hovered or dragging. */ decoration?: HandleDecoration /** @@ -334,6 +347,7 @@ export type EndpointMoveHandle = { /** Called with the world-space hit on the ground plane. */ apply: (node: N, worldPoint: readonly [number, number, number], sceneApi: SceneApi) => Partial portal?: HandlePortal + portalTarget?: HandlePortalTarget } // Default to `any` so type-erased renderers can hold `HandleDescriptor[]` @@ -382,7 +396,9 @@ export type TapActionHandle = { * stands it up against the node's facing plane (a wall face). */ plane?: 'horizontal' | 'node-normal' + visible?: (node: N, sceneApi: SceneApi) => boolean portal?: HandlePortal + portalTarget?: HandlePortalTarget cursor?: Cursor } @@ -429,6 +445,7 @@ export type TranslateHandle = { */ snapExtents?: (node: N, sceneApi: SceneApi) => readonly [number, number] | null portal?: HandlePortal + portalTarget?: HandlePortalTarget } /** @@ -448,6 +465,7 @@ export type LatchHandle = { group: string placement: HandlePlacement portal?: HandlePortal + portalTarget?: HandlePortalTarget } export type HandleDescriptor = diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index bf08161e06..c6d883da5d 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -102,9 +102,19 @@ export { OpeningDimensionReference, } from './nodes/door' export { + createDormerDefaultWindow, DormerNode, type DormerSurfaceMaterialRole, type DormerSurfaceMaterialSpec, + DormerWallFace, + dormerPointToWallFace, + dormerWallFacePointToDormer, + getDormerDefaultWindowFace, + getDormerExposedFaces, + getDormerWallFaceFrame, + getDormerWallHorizontalBoundsAtHeight, + getDormerWallOpeningVerticalBounds, + getDormerWallVerticalBounds, getEffectiveDormerSurfaceMaterial, } from './nodes/dormer' export { @@ -161,6 +171,7 @@ export { LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, } from './nodes/item' export { + LeanToCanopyForm, LeanToConnectionMode, LeanToEndCondition, LeanToExtensionNode, @@ -197,7 +208,7 @@ export { type RidgeVentLine, RidgeVentNode, } from './nodes/ridge-vent' -export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' +export type { RoofSupport, RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export type { DutchRoofMetrics, @@ -208,6 +219,7 @@ export type { } from './nodes/roof-segment' export { getActiveRoofHeight, + getConicalRoofCoverage, getDutchRoofMetrics, getEffectiveSegmentSurfaceMaterial, getPitchFromActiveRoofHeight, diff --git a/packages/core/src/schema/nodes/dormer.ts b/packages/core/src/schema/nodes/dormer.ts index a5702caa7f..84bbcbcd29 100644 --- a/packages/core/src/schema/nodes/dormer.ts +++ b/packages/core/src/schema/nodes/dormer.ts @@ -2,7 +2,8 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' -import { RoofType } from './roof-segment' +import { getRoofSegmentSurfaceY, type RoofSegmentNode, RoofType } from './roof-segment' +import { WindowNode } from './window' export type DormerSurfaceMaterialRole = 'top' | 'side' | 'wall' export type DormerSurfaceMaterialSpec = { @@ -10,6 +11,15 @@ export type DormerSurfaceMaterialSpec = { materialPreset?: string } +export const DormerWallFace = z.enum(['front', 'back', 'right', 'left']) +export type DormerWallFace = z.infer + +export type DormerWallFaceFrame = { + origin: [number, number, number] + yaw: number + width: number +} + /** * Default dormer dimensions and window controls. Values match the * legacy archive so existing scenes don't shift visually. @@ -65,15 +75,15 @@ export const DormerNode = BaseNode.extend({ roofType: RoofType.default('gable'), roofHeight: z.number().default(DORMER_DEFAULTS.ROOF_HEIGHT), + shedHighSide: z.enum(['back', 'front']).default('back'), // Height of the hung wall (the "skirt") that extends below the eave // into the host roof — this is the wall area the window opening is // cut through. Larger values let the dormer host taller windows. wallSkirtHeight: z.number().default(DORMER_DEFAULTS.WALL_SKIRT_HEIGHT), - // Window is rendered as parametric geometry on the dormer's front - // face — not a child node. The fields below mirror the legacy panel - // controls; geometry beyond the simple opening box is deferred. + // Legacy inline-window controls. Existing scenes are promoted to a hosted + // WindowNode during scene migration; these fields remain for archive data. windowWidth: z.number().default(DORMER_DEFAULTS.WINDOW_WIDTH), windowHeight: z.number().default(DORMER_DEFAULTS.WINDOW_HEIGHT), windowOffsetX: z.number().default(DORMER_DEFAULTS.WINDOW_OFFSET_X), @@ -94,18 +104,222 @@ export const DormerNode = BaseNode.extend({ windowSill: z.boolean().default(false), windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH), windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS), + + // Hosted windows use the same recursive scene-graph contract as walls and + // roof segments. Legacy window* fields above are retained for migration. + children: z.array(WindowNode.shape.id).default([]), }).describe( dedent` Dormer — a small house-shaped protrusion sitting on top of a roof segment. width × depth × height defines the box base; roofType and - roofHeight define the dormer's own roof shape. The window opening - is parametric geometry on the dormer's front face, not a hosted - child node. + roofHeight define the dormer's own roof shape. shedHighSide controls + the pitch direction for shed roofs. WindowNode children are hosted on + its wall faces and use the regular window item model. `, ) export type DormerNode = z.infer +export function getDormerWallFaceFrame( + dormer: Pick, + face: DormerWallFace, +): DormerWallFaceFrame { + switch (face) { + case 'back': + return { origin: [0, 0, -dormer.depth / 2], yaw: Math.PI, width: dormer.width } + case 'right': + return { origin: [dormer.width / 2, 0, 0], yaw: Math.PI / 2, width: dormer.depth } + case 'left': + return { origin: [-dormer.width / 2, 0, 0], yaw: -Math.PI / 2, width: dormer.depth } + default: + return { origin: [0, 0, dormer.depth / 2], yaw: 0, width: dormer.width } + } +} + +export function dormerWallFacePointToDormer( + dormer: Pick, + face: DormerWallFace, + point: [number, number, number], +): [number, number, number] { + const frame = getDormerWallFaceFrame(dormer, face) + const cos = Math.cos(frame.yaw) + const sin = Math.sin(frame.yaw) + const [x, y, z] = point + return [ + frame.origin[0] + x * cos + z * sin, + frame.origin[1] + y, + frame.origin[2] - x * sin + z * cos, + ] +} + +export function dormerPointToWallFace( + dormer: Pick, + face: DormerWallFace, + point: [number, number, number], +): [number, number, number] { + const frame = getDormerWallFaceFrame(dormer, face) + const cos = Math.cos(frame.yaw) + const sin = Math.sin(frame.yaw) + const dx = point[0] - frame.origin[0] + const dz = point[2] - frame.origin[2] + return [cos * dx - sin * dz, point[1] - frame.origin[1], sin * dx + cos * dz] +} + +export function getDormerWallVerticalBounds( + dormer: Pick, +) { + return { + min: -(dormer.wallSkirtHeight ?? DORMER_DEFAULTS.WALL_SKIRT_HEIGHT), + max: Math.max(0, dormer.height), + } +} + +type DormerWallProfile = Pick< + DormerNode, + 'width' | 'depth' | 'height' | 'wallSkirtHeight' | 'roofType' | 'roofHeight' | 'shedHighSide' +> + +function getDormerWallCeilingAt( + dormer: DormerWallProfile, + face: DormerWallFace, + faceX: number, +): number { + const eaveHeight = Math.max(0, dormer.height) + if (dormer.roofType !== 'shed') return eaveHeight + + const depth = Math.max(dormer.depth, Number.EPSILON) + const [, , dormerZ] = dormerWallFacePointToDormer(dormer, face, [faceX, 0, 0]) + const frontWeight = Math.max(0, Math.min(1, dormerZ / depth + 0.5)) + const highSideWeight = dormer.shedHighSide === 'front' ? frontWeight : 1 - frontWeight + return eaveHeight + Math.max(0, dormer.roofHeight) * highSideWeight +} + +export function getDormerWallOpeningVerticalBounds( + dormer: DormerWallProfile, + face: DormerWallFace, + centerX: number, + width: number, +) { + const halfWidth = width / 2 + return { + min: -(dormer.wallSkirtHeight ?? DORMER_DEFAULTS.WALL_SKIRT_HEIGHT), + max: Math.min( + getDormerWallCeilingAt(dormer, face, centerX - halfWidth), + getDormerWallCeilingAt(dormer, face, centerX + halfWidth), + ), + } +} + +export function getDormerWallHorizontalBoundsAtHeight( + dormer: DormerWallProfile, + face: DormerWallFace, + height: number, +) { + const halfWidth = getDormerWallFaceFrame(dormer, face).width / 2 + const leftCeiling = getDormerWallCeilingAt(dormer, face, -halfWidth) + const rightCeiling = getDormerWallCeilingAt(dormer, face, halfWidth) + + if (height <= Math.min(leftCeiling, rightCeiling)) { + return { min: -halfWidth, max: halfWidth } + } + if (leftCeiling === rightCeiling) { + return { min: -halfWidth, max: halfWidth } + } + + const crossing = + -halfWidth + ((height - leftCeiling) / (rightCeiling - leftCeiling)) * (halfWidth * 2) + + if (rightCeiling > leftCeiling) { + const min = Math.min(halfWidth, crossing) + return { min, max: halfWidth } + } + + const max = Math.max(-halfWidth, crossing) + return { min: -halfWidth, max } +} + +const DORMER_WINDOW_CENTER_MIN_CLEARANCE = 0.01 + +export function getDormerExposedFaces( + dormer: Pick, + hostSegment: RoofSegmentNode, +): { front: boolean; back: boolean } { + const halfDepth = dormer.depth / 2 + const [dormerX, dormerY, dormerZ] = dormer.position + const faceDX = halfDepth * Math.sin(dormer.rotation) + const faceDZ = halfDepth * Math.cos(dormer.rotation) + const windowCenterY = dormerY - dormer.wallSkirtHeight / 2 + dormer.windowOffsetY + const clears = (faceX: number, faceZ: number) => + windowCenterY - getRoofSegmentSurfaceY(hostSegment, faceX, faceZ) > + DORMER_WINDOW_CENTER_MIN_CLEARANCE + + return { + front: clears(dormerX + faceDX, dormerZ + faceDZ), + back: clears(dormerX - faceDX, dormerZ - faceDZ), + } +} + +export function getDormerDefaultWindowFace( + dormer: Pick, + hostSegment?: RoofSegmentNode, +): Extract { + if (!hostSegment) return 'front' + const exposed = getDormerExposedFaces(dormer, hostSegment) + return !exposed.front && exposed.back ? 'back' : 'front' +} + +export function createDormerDefaultWindow( + dormer: Pick< + DormerNode, + | 'id' + | 'width' + | 'wallSkirtHeight' + | 'windowWidth' + | 'windowHeight' + | 'windowOffsetX' + | 'windowOffsetY' + | 'windowFrameThickness' + | 'windowFrameDepth' + | 'windowColumns' + | 'windowRows' + | 'windowDividerThickness' + | 'windowShape' + | 'windowArchHeight' + | 'windowCornerRadii' + | 'windowSill' + | 'windowSillDepth' + | 'windowSillThickness' + >, + id: string, + face: Extract = 'front', +): WindowNode { + const skirt = dormer.wallSkirtHeight ?? DORMER_DEFAULTS.WALL_SKIRT_HEIGHT + const equalRatios = (count: number) => Array.from({ length: Math.max(1, count) }, () => 1) + return WindowNode.parse({ + id, + parentId: dormer.id, + dormerId: dormer.id, + dormerFace: face, + position: [dormer.windowOffsetX, -skirt / 2 + dormer.windowOffsetY, 0], + rotation: [0, 0, 0], + side: 'front', + width: dormer.windowWidth, + height: dormer.windowHeight, + openingShape: dormer.windowShape, + archHeight: dormer.windowArchHeight, + openingCornerRadii: dormer.windowCornerRadii, + frameThickness: dormer.windowFrameThickness, + frameDepth: dormer.windowFrameDepth, + columnRatios: equalRatios(dormer.windowColumns), + rowRatios: equalRatios(dormer.windowRows), + columnDividerThickness: dormer.windowDividerThickness, + rowDividerThickness: dormer.windowDividerThickness, + sill: dormer.windowSill, + sillDepth: dormer.windowSillDepth, + sillThickness: dormer.windowSillThickness, + }) +} + /** * Per-surface material resolution. Fall-through order: * top → topMaterial[Preset] → legacy diff --git a/packages/core/src/schema/nodes/lean-to-extension.ts b/packages/core/src/schema/nodes/lean-to-extension.ts index 127e3bdb8d..68ac1040b4 100644 --- a/packages/core/src/schema/nodes/lean-to-extension.ts +++ b/packages/core/src/schema/nodes/lean-to-extension.ts @@ -3,8 +3,11 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { ColumnNode } from './column' import { RoofNode } from './roof' +import { SlabNode } from './slab' export const LeanToConnectionMode = z.enum(['auto', 'manual']) +export const LeanToCanopyForm = z.enum(['mono', 'gable', 'butterfly']) +export const LeanToHostKind = z.enum(['wall', 'slab-edge', 'freestanding', 'conical-roof']) export const LeanToRoofEdge = z.enum(['+X', '-X', '+Z', '-Z']) export const LeanToResizeLock = z.enum([ 'preserve-high-edge', @@ -17,9 +20,15 @@ export const LeanToHighSideMode = z.enum(['wall-ledger', 'independent-high-beam' export const LeanToPostLayoutMode = z.enum(['count', 'target-spacing']) export const LeanToFootingStyle = z.enum(['none', 'base-plate', 'concrete-pad']) export const LeanToCoveringType = z.enum(['generic', 'shingle', 'metal-panel']) +const LeanToOmittedPostSlot = z.object({ + side: z.enum(['low', 'high']), + index: z.number().int(), + layoutCount: z.number().int().min(1), +}) const DEFAULT_LOW_EDGE_HEIGHT = 2.7 - 3 * Math.tan((5 * Math.PI) / 180) const DEFAULT_LEAN_TO_POST_SPACING = 3 export type LeanToConnectionMode = z.infer +export type LeanToCanopyForm = z.infer export type LeanToRoofEdge = z.infer export const LeanToExtensionNode = BaseNode.extend({ @@ -28,6 +37,12 @@ export const LeanToExtensionNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), children: z.array(z.union([ColumnNode.shape.id, RoofNode.shape.id])).default([]), + canopyForm: LeanToCanopyForm.default('mono'), + hostKind: LeanToHostKind.default('wall'), + hostHeightOffset: z.number().min(-10).max(10).default(0), + hostSlabId: SlabNode.shape.id.optional(), + hostSlabEdgeIndex: z.number().int().min(0).optional(), + hostSlabEdgeT: z.number().min(0).max(1).optional(), span: z.number().min(0.5).max(100).default(4), autoSpan: z.boolean().default(true), @@ -101,15 +116,16 @@ export const LeanToExtensionNode = BaseNode.extend({ postLayoutMode: LeanToPostLayoutMode.default('target-spacing'), postSpacing: z.number().min(0.3).max(10).default(DEFAULT_LEAN_TO_POST_SPACING), postInset: z.number().min(0).max(3).default(0), + omittedPostSlots: z.array(LeanToOmittedPostSlot).default([]), postBracing: z.enum(['none', 'knee']).default('none'), footingStyle: LeanToFootingStyle.default('none'), }).describe( dedent` - Wall-hosted lean-to roof extension. - The high edge attaches to the host wall and the mono-pitch roof falls along - local +Z to a beam supported by a managed row of column children. Its roof is a standard - shed roof segment with standard gutter and downspout children. It is an open canopy, not a - standalone enclosed shed roof. + Open parametric canopy. + The high edge can attach to a wall, attach to an upper slab edge, stand on an independent + high beam, or wrap around a conical roof's cylindrical base. Attached canopies use a mono-pitch + roof. Freestanding canopies can use a mono-pitch, gable, or butterfly roof with managed columns, + framing, gutters, and downspouts. `, ) diff --git a/packages/core/src/schema/nodes/ridge-vent.ts b/packages/core/src/schema/nodes/ridge-vent.ts index 31433d88b3..5b5925f32d 100644 --- a/packages/core/src/schema/nodes/ridge-vent.ts +++ b/packages/core/src/schema/nodes/ridge-vent.ts @@ -79,7 +79,13 @@ export function getRidgeVentLinesForSegment(segment: RoofSegmentNode): RidgeVent trim: UNTRIMMED_RIDGE_VENT_BOUNDS_TRIM, }) const { width, depth, minX, maxX, minZ, maxZ } = bounds - if (segment.roofType === 'flat' || segment.roofType === 'shed') return [] + if ( + segment.roofType === 'flat' || + segment.roofType === 'shed' || + segment.roofType === 'conical' + ) { + return [] + } const halfW = width / 2 const halfD = depth / 2 diff --git a/packages/core/src/schema/nodes/roof-segment-coverage.test.ts b/packages/core/src/schema/nodes/roof-segment-coverage.test.ts new file mode 100644 index 0000000000..687879f7aa --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-coverage.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test' +import { getConicalRoofCoverage, RoofSegmentNode } from './roof-segment' + +describe('conical roof coverage', () => { + test('keeps an existing clipped sector when full circle is off', () => { + const node = RoofSegmentNode.parse({ + roofType: 'conical', + conicalStartAngle: Math.PI / 4, + conicalSweepAngle: Math.PI, + }) + + expect(getConicalRoofCoverage(node)).toEqual({ + fullCircle: false, + startAngle: Math.PI / 4, + sweepAngle: Math.PI, + }) + }) + + test('temporarily ignores clipping angles when full circle is on', () => { + const node = RoofSegmentNode.parse({ + roofType: 'conical', + conicalFullCircle: true, + conicalStartAngle: Math.PI / 4, + conicalSweepAngle: Math.PI, + }) + + expect(getConicalRoofCoverage(node)).toEqual({ + fullCircle: true, + startAngle: 0, + sweepAngle: -Math.PI * 2, + }) + expect(node.conicalStartAngle).toBe(Math.PI / 4) + expect(node.conicalSweepAngle).toBe(Math.PI) + }) + + test('infers legacy full cones and clipped sectors', () => { + expect(getConicalRoofCoverage(RoofSegmentNode.parse({ roofType: 'conical' })).fullCircle).toBe( + true, + ) + expect( + getConicalRoofCoverage( + RoofSegmentNode.parse({ roofType: 'conical', conicalSweepAngle: -Math.PI / 2 }), + ).fullCircle, + ).toBe(false) + }) + + test('uses a half circle when a legacy full cone is switched to clipped', () => { + const node = RoofSegmentNode.parse({ roofType: 'conical', conicalFullCircle: false }) + + expect(getConicalRoofCoverage(node)).toEqual({ + fullCircle: false, + startAngle: 0, + sweepAngle: -Math.PI, + }) + }) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-shape.test.ts b/packages/core/src/schema/nodes/roof-segment-shape.test.ts index 64ff58a17a..39ae8a572d 100644 --- a/packages/core/src/schema/nodes/roof-segment-shape.test.ts +++ b/packages/core/src/schema/nodes/roof-segment-shape.test.ts @@ -7,6 +7,69 @@ import { } from './roof-segment-shape' describe('roof segment shape', () => { + test('conical shell is circular, closed, and rises to one apex', () => { + const faces = getRoofModuleFaces({ + type: 'conical', + w: 8, + d: 8, + wh: 2, + rh: 4, + baseY: 0, + insets: {}, + baseW: 8, + baseD: 8, + tanTheta: 1, + shapeRatios: getRoofShapeRatios({}), + }) + const bottom = faces[0] + const roofFaces = faces.filter((face) => face.some((vertex) => vertex.y === 6)) + + expect(faces).toHaveLength(97) + expect(bottom).toHaveLength(48) + expect(bottom.every((vertex) => Math.abs(Math.hypot(vertex.x, vertex.z) - 4) < 1e-6)).toBe(true) + expect(roofFaces).toHaveLength(48) + expect( + roofFaces.every( + (face) => + face.filter((vertex) => vertex.x === 0 && vertex.y === 6 && vertex.z === 0).length === 1, + ), + ).toBe(true) + }) + + test('conical sector is clipped to its sweep and closes both cut faces', () => { + const faces = getRoofModuleFaces({ + type: 'conical', + w: 8, + d: 8, + wh: 2, + rh: 4, + baseY: 0, + insets: {}, + baseW: 8, + baseD: 8, + tanTheta: 1, + shapeRatios: getRoofShapeRatios({}), + conicalStartAngle: 0, + conicalSweepAngle: Math.PI, + }) + const radialCutFaces = faces.filter( + (face) => + face.length === 4 && + face.some((vertex) => vertex.x === 0 && vertex.y === 0 && vertex.z === 0) && + face.some((vertex) => vertex.x === 0 && vertex.y === 6 && vertex.z === 0), + ) + + expect(faces).toHaveLength(51) + expect(faces[0]).toHaveLength(26) + expect(radialCutFaces).toHaveLength(2) + expect( + faces + .flat() + .filter((vertex) => Math.abs(Math.hypot(vertex.x, vertex.z) - 4) < 1e-6) + .every((vertex) => vertex.z >= -1e-6), + ).toBe(true) + }) + test('dutch shell is built as one complete non-duplicated face set', () => { const wh = 3 const rh = 2 diff --git a/packages/core/src/schema/nodes/roof-segment-shape.ts b/packages/core/src/schema/nodes/roof-segment-shape.ts index d4d7b2db45..ee5b7c065e 100644 --- a/packages/core/src/schema/nodes/roof-segment-shape.ts +++ b/packages/core/src/schema/nodes/roof-segment-shape.ts @@ -10,6 +10,8 @@ export type RoofShapeEaveSide = '+X' | '-X' | '+Z' | '-Z' export function getRoofShapeEaveSides(type: RoofType): RoofShapeEaveSide[] { switch (type) { + case 'conical': + return [] case 'shed': return ['+Z'] case 'gable': @@ -158,7 +160,12 @@ export function getRoofShapeInsets(input: { let iB = 0 let iL = 0 let iR = 0 - if (input.roofType === 'hip' || input.roofType === 'mansard' || input.roofType === 'dutch') { + if ( + input.roofType === 'hip' || + input.roofType === 'mansard' || + input.roofType === 'dutch' || + input.roofType === 'conical' + ) { iF = inset iB = inset iL = inset @@ -287,10 +294,69 @@ export function getRoofModuleFaces(input: { shapeRatios: RoofShapeRatios excludeDutchEndSlopes?: boolean dutchTopRakeThickness?: number + conicalStartAngle?: number + conicalSweepAngle?: number }): RoofShapeFaceVertex[][] { const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z }) const { iF = 0, iB = 0, iL = 0, iR = 0 } = input.insets + if (input.type === 'conical') { + const startAngle = Number.isFinite(input.conicalStartAngle) ? input.conicalStartAngle! : 0 + const requestedSweep = Number.isFinite(input.conicalSweepAngle) + ? input.conicalSweepAngle! + : -Math.PI * 2 + const sweepAngle = Math.max( + -Math.PI * 2, + Math.min(Math.PI * 2, Math.abs(requestedSweep) < 1e-4 ? 1e-4 : requestedSweep), + ) + const isFullCone = Math.abs(sweepAngle) >= Math.PI * 2 - 1e-4 + const radialSegments = isFullCone + ? 48 + : Math.max(1, Math.ceil((48 * Math.abs(sweepAngle)) / (Math.PI * 2))) + const eaveRadius = Math.max(0.005, input.w / 2) + const radialInset = (iF + iB + iL + iR) / 4 + const baseRadius = Math.max(0.005, eaveRadius - radialInset) + const eaveY = input.wh + const peak = v(0, input.wh + Math.max(0.001, input.rh), 0) + const ringPointCount = isFullCone ? radialSegments : radialSegments + 1 + const bottomRing = Array.from({ length: ringPointCount }, (_, index) => { + const angle = startAngle + (index / radialSegments) * sweepAngle + return v(Math.cos(angle) * baseRadius, input.baseY, Math.sin(angle) * baseRadius) + }) + const eaveRing = Array.from({ length: ringPointCount }, (_, index) => { + const angle = startAngle + (index / radialSegments) * sweepAngle + return v(Math.cos(angle) * eaveRadius, eaveY, Math.sin(angle) * eaveRadius) + }) + const bottomCenter = v(0, input.baseY, 0) + const eaveCenter = v(0, eaveY, 0) + const faces: RoofShapeFaceVertex[][] = [ + isFullCone ? [...bottomRing].reverse() : [bottomCenter, ...[...bottomRing].reverse()], + ] + + for (let index = 0; index < radialSegments; index += 1) { + const next = isFullCone ? (index + 1) % radialSegments : index + 1 + faces.push([bottomRing[index]!, bottomRing[next]!, eaveRing[next]!, eaveRing[index]!]) + } + + if (input.rh === 0) { + faces.push(isFullCone ? [...eaveRing].reverse() : [eaveCenter, ...[...eaveRing].reverse()]) + } else { + for (let index = 0; index < radialSegments; index += 1) { + const next = isFullCone ? (index + 1) % radialSegments : index + 1 + faces.push([eaveRing[index]!, eaveRing[next]!, peak]) + } + } + + if (!isFullCone) { + faces.push( + [bottomCenter, bottomRing[0]!, eaveRing[0]!, peak], + [bottomCenter, peak, eaveRing.at(-1)!, bottomRing.at(-1)!], + ) + } + + return sweepAngle > 0 ? faces.map((face) => [...face].reverse()) : faces + } + const b1 = v(-input.w / 2 + iL, input.baseY, input.d / 2 - iF) const b2 = v(input.w / 2 - iR, input.baseY, input.d / 2 - iF) const b3 = v(input.w / 2 - iR, input.baseY, -input.d / 2 + iB) diff --git a/packages/core/src/schema/nodes/roof-segment-surface.test.ts b/packages/core/src/schema/nodes/roof-segment-surface.test.ts index 17ba53af01..57bec7db01 100644 --- a/packages/core/src/schema/nodes/roof-segment-surface.test.ts +++ b/packages/core/src/schema/nodes/roof-segment-surface.test.ts @@ -2,6 +2,21 @@ import { describe, expect, test } from 'bun:test' import { getRoofSegmentSurfaceY, ROOF_SHAPE_DEFAULTS, RoofSegmentNode } from './roof-segment' describe('getRoofSegmentSurfaceY', () => { + test('falls linearly from a conical apex in every radial direction', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 2, + pitch: 45, + }) + + expect(getRoofSegmentSurfaceY(segment, 0, 0)).toBeCloseTo(6, 6) + expect(getRoofSegmentSurfaceY(segment, 2, 0)).toBeCloseTo(4, 6) + expect(getRoofSegmentSurfaceY(segment, 0, 4)).toBeCloseTo(2, 6) + expect(getRoofSegmentSurfaceY(segment, Math.SQRT2, Math.SQRT2)).toBeCloseTo(4, 6) + }) + test('keeps the Dutch width-axis rake on the upper gable slope', () => { const segment = RoofSegmentNode.parse({ roofType: 'dutch', diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 7e5a5ecd6b..1fefc4809c 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -4,7 +4,16 @@ import { BaseNode, nodeType, objectId } from '../base' import type { MaterialSchema as MaterialSchemaType } from '../material' import { MaterialSchema } from '../material' -export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat']) +export const RoofType = z.enum([ + 'hip', + 'gable', + 'shed', + 'gambrel', + 'dutch', + 'mansard', + 'flat', + 'conical', +]) export type RoofType = z.infer @@ -106,6 +115,18 @@ export const RoofSegmentNode = BaseNode.extend({ // Footprint dimensions width: z.number().default(8), depth: z.number().default(6), + // Angular extent of a conical roof. A full cone uses 2π. A signed + // sweep preserves the direction of the curved wall used to create a + // conical sector; other roof types ignore both fields. + conicalStartAngle: z.number().optional(), + conicalSweepAngle: z + .number() + .min(-Math.PI * 2) + .max(Math.PI * 2) + .optional(), + // Overrides the stored sector angles without discarding them, allowing + // the panel to switch back to the original clipped wall sweep. + conicalFullCircle: z.boolean().optional(), // Segment-local distances trimmed from each footprint side. The trim // boundary is projected vertically through the roof volume, so the // resulting edge follows the actual sloped roof surfaces. @@ -137,6 +158,9 @@ export const RoofSegmentNode = BaseNode.extend({ shedSideInfillMaxX: z.number().optional(), shedFootprintPieces: z.array(z.array(z.tuple([z.number(), z.number()])).min(3)).optional(), shedOpenEndSides: z.array(z.enum(['left', 'right'])).optional(), + managedByParent: z.boolean().default(false), + wallShell: z.enum(['auto', 'include', 'omit']).default('auto'), + shedInsetEndPanels: z.boolean().default(false), // Shape-specific ratios. Only the pair matching `roofType` is read; the // rest are inert. Defined on every segment so the panel can flip // roofType without losing the previous shape's tuning. @@ -195,8 +219,10 @@ export const RoofSegmentNode = BaseNode.extend({ Roof segment node - an individual roof module within a roof group. Each segment generates a complete architectural volume (walls + roof). Multiple segments can be combined to form complex roof shapes. - - roofType: hip, gable, shed, gambrel, dutch, mansard, flat + - roofType: hip, gable, shed, gambrel, dutch, mansard, flat, conical - width/depth: footprint dimensions + - conicalStartAngle / conicalSweepAngle: angular extent of a conical sector (radians) + - conicalFullCircle: temporarily render the complete cone while preserving the sector angles - trim: segment-local side cut distances - wallHeight: height of walls below the roof - pitch: roof slope in degrees (angle of the primary slope face) @@ -214,6 +240,28 @@ export const RoofSegmentNode = BaseNode.extend({ export type RoofSegmentNode = z.infer +export function getConicalRoofCoverage( + node: Pick, +): { + fullCircle: boolean + startAngle: number + sweepAngle: number +} { + const storedSweep = node.conicalSweepAngle + const inferredFullCircle = + storedSweep === undefined || Math.abs(storedSweep) >= Math.PI * 2 - 1e-4 + const fullCircle = node.conicalFullCircle ?? inferredFullCircle + if (fullCircle) { + return { fullCircle: true, startAngle: 0, sweepAngle: -Math.PI * 2 } + } + const hasClippedSweep = storedSweep !== undefined && Math.abs(storedSweep) < Math.PI * 2 - 1e-4 + return { + fullCircle: false, + startAngle: node.conicalStartAngle ?? 0, + sweepAngle: hasClippedSweep ? storedSweep : -Math.PI, + } +} + function finiteNonNegative(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 } @@ -453,6 +501,8 @@ export function getDutchRoofMetrics( function getPrimarySlopeRun(input: PitchInputs & ShapeRatios): number { const min = Math.min(input.width, input.depth) switch (input.roofType) { + case 'conical': + return input.width / 2 case 'shed': return input.depth case 'gable': @@ -560,6 +610,7 @@ export function getRoofSegmentVisibleTopBounds( if ( segment.roofType === 'hip' || + segment.roofType === 'conical' || segment.roofType === 'mansard' || segment.roofType === 'dutch' ) { @@ -675,6 +726,12 @@ export function getRoofSegmentSurfaceY( return peakY - Math.max(fx, fz) * activeRh } + if (node.roofType === 'conical') { + const radius = Math.max(0.0001, node.width / 2) + const radialProgress = Math.min(1, Math.hypot(localX, localZ) / radius) + return peakY - radialProgress * activeRh + } + const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 return peakY - t * activeRh } diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index 89d806b288..cc2ed84915 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -11,6 +11,20 @@ export type RoofSurfaceMaterialSpec = { materialPreset?: string } +export const RoofSupport = z + .discriminatedUnion('kind', [ + z.object({ kind: z.literal('level') }), + z.object({ + kind: z.literal('roof'), + roofSegmentId: RoofSegmentNode.shape.id, + localPosition: z.tuple([z.number(), z.number()]), + curbHeight: z.number().min(0).default(0.5), + }), + ]) + .default({ kind: 'level' }) + +export type RoofSupport = z.infer + export const RoofNode = BaseNode.extend({ id: objectId('roof'), type: nodeType('roof'), @@ -25,6 +39,7 @@ export const RoofNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), + support: RoofSupport, // Child roof segment IDs children: z.array(RoofSegmentNode.shape.id).default([]), }).describe( @@ -34,6 +49,7 @@ export const RoofNode = BaseNode.extend({ When not being edited, segments are visually combined into a single solid. - position: center position of the roof group - rotation: rotation around Y axis + - support: level placement or an explicit roof-surface attachment - children: array of RoofSegmentNode IDs `, ) diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 53b7fec556..1cc3b23666 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -42,6 +42,10 @@ export const WindowNode = BaseNode.extend({ // Wall reference wallId: z.string().optional(), + // Alternative host: a dormer's generated wall face. When set, `position` + // is FACE-LOCAL — [u along the face, v height, z from the wall mid-plane]. + dormerId: z.string().optional(), + dormerFace: z.enum(['front', 'back', 'right', 'left']).optional(), // Alternative host: a roof-segment's generated wall face (base wall // under the roof or a coplanar gable end). When set, `position` is // FACE-LOCAL — [u along the face, v height, z from the wall mid-plane] diff --git a/packages/core/src/store/use-scene-window-migration.test.ts b/packages/core/src/store/use-scene-window-migration.test.ts index 6911d86b58..1fae38a541 100644 --- a/packages/core/src/store/use-scene-window-migration.test.ts +++ b/packages/core/src/store/use-scene-window-migration.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'bun:test' -import type { AnyNode } from '../schema' +import { type AnyNode, getRoofSegmentSurfaceY, type RoofSegmentNode } from '../schema' import useScene from './use-scene' describe('scene window migrations', () => { @@ -89,4 +89,91 @@ describe('scene window migrations', () => { expect(window.height).toBe(1.5) expect(window.wallId).toBe('wall_test') }) + + test('promotes a legacy dormer window into a hosted window child', () => { + useScene.getState().setScene( + { + dormer_test: { + object: 'node', + id: 'dormer_test', + type: 'dormer', + parentId: null, + visible: true, + metadata: {}, + roofSegmentId: 'segment_test', + width: 3, + depth: 2, + wallSkirtHeight: 2.5, + windowWidth: 0.8, + windowHeight: 1.2, + windowOffsetX: 0.4, + windowOffsetY: 1, + windowColumns: 2, + windowRows: 3, + }, + } as unknown as Record, + ['dormer_test'] as never, + ) + + const dormer = useScene.getState().nodes.dormer_test as Extract + const childId = dormer.children[0] + const window = useScene.getState().nodes[childId] as Extract + + expect(childId).toMatch(/^window_test_default/) + expect(window.parentId).toBe('dormer_test') + expect(window.dormerId).toBe('dormer_test') + expect(window.dormerFace).toBe('front') + expect(window.position).toEqual([0.4, -0.25, 0]) + expect(window.columnRatios).toEqual([1, 1]) + expect(window.rowRatios).toEqual([1, 1, 1]) + }) + + test('puts the promoted window on the exposed dormer face', () => { + const segment = { + object: 'node', + id: 'rseg_test', + type: 'roof-segment', + parentId: null, + visible: true, + metadata: {}, + children: ['dormer_test'], + position: [0, 0, 0], + rotation: 0, + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 40, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + } as RoofSegmentNode + const dormerY = getRoofSegmentSurfaceY(segment, 0, -1.5) + + useScene.getState().setScene( + { + rseg_test: segment, + dormer_test: { + object: 'node', + id: 'dormer_test', + type: 'dormer', + parentId: 'rseg_test', + visible: true, + metadata: {}, + roofSegmentId: 'rseg_test', + position: [0, dormerY, -1.5], + rotation: 0, + }, + } as unknown as Record, + ['rseg_test'] as never, + ) + + const dormer = useScene.getState().nodes.dormer_test as Extract + const window = useScene.getState().nodes[dormer.children[0]] as Extract< + AnyNode, + { type: 'window' } + > + expect(window.dormerFace).toBe('back') + }) }) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index cd5f23a772..7be3cc3a8d 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -9,6 +9,11 @@ import { BuildingNode } from '../schema' import type { Collection, CollectionId } from '../schema/collections' import { generateCollectionId } from '../schema/collections' import { DoorNode as DoorNodeSchema } from '../schema/nodes/door' +import { + createDormerDefaultWindow, + DormerNode as DormerNodeSchema, + getDormerDefaultWindowFace, +} from '../schema/nodes/dormer' import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator' import { LevelNode, normalizeLevelBaseElevation } from '../schema/nodes/level' import { @@ -796,6 +801,36 @@ function migrateNodes(nodes: Record): { } } + // Dormers originally rendered one inline parametric window. Promote that + // default to a real hosted WindowNode so additional windows can use the + // regular window tool and inspector without changing the old appearance. + if (node.type === 'dormer') { + const dormer = DormerNodeSchema.parse({ + ...patchedNodes[id], + children: getStringArray((patchedNodes[id] as { children?: unknown }).children), + }) + const children = getStringArray(dormer.children) + const hasHostedWindow = children.some((childId) => patchedNodes[childId]?.type === 'window') + if (!hasHostedWindow) { + const baseWindowId = `window_${id.replace(/^dormer_/, '')}_default` + let windowId = baseWindowId + let suffix = 1 + while (patchedNodes[windowId]) { + windowId = `${baseWindowId}_${suffix}` + suffix += 1 + } + const host = dormer.roofSegmentId ? patchedNodes[dormer.roofSegmentId] : undefined + const hostSegment = host?.type === 'roof-segment' ? (host as RoofSegmentNode) : undefined + const window = createDormerDefaultWindow( + dormer, + windowId, + getDormerDefaultWindowFace(dormer, hostSegment), + ) + patchedNodes[windowId] = window + patchedNodes[id] = { ...dormer, children: [...children, window.id] } + } + } + if (node.type === 'construction-dimension') { patchedNodes[id] = migrateConstructionDimension(node) } diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index 5ba5f46d58..cbb0668d36 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -277,3 +277,54 @@ describe('lean-to roof attachment remap', () => { expect(levelLeanTo.hostRoofSegmentId).toBe(levelClone.idMap.get('roofseg_1')) }) }) + +describe('roof surface support remap', () => { + test('remaps a mounted roof support segment in whole-scene and level clones', () => { + const level = makeNode('level_1', 'level', { + children: ['roof_host', 'roof_mounted'], + }) + const host = makeNode('roof_host', 'roof', { + parentId: 'level_1', + children: ['rseg_host'], + }) + const hostSegment = makeNode('rseg_host', 'roof-segment', { + parentId: 'roof_host', + }) + const mounted = makeNode('roof_mounted', 'roof', { + parentId: 'level_1', + support: { + kind: 'roof', + roofSegmentId: 'rseg_host', + localPosition: [1, 2], + curbHeight: 0.5, + }, + }) + const nodes = { + ['level_1' as AnyNodeId]: level, + ['roof_host' as AnyNodeId]: host, + ['rseg_host' as AnyNodeId]: hostSegment, + ['roof_mounted' as AnyNodeId]: mounted, + } + + const whole = cloneSceneGraph({ nodes, rootNodeIds: ['level_1' as AnyNodeId] }) + const wholeHostSegment = Object.values(whole.nodes).find( + (node) => node.type === 'roof-segment', + )! + const wholeMounted = Object.values(whole.nodes).find( + (node) => node.type === 'roof' && node.support?.kind === 'roof', + )! + expect(wholeMounted.type).toBe('roof') + if (wholeMounted.type === 'roof' && wholeMounted.support.kind === 'roof') { + expect(wholeMounted.support.roofSegmentId).toBe(wholeHostSegment.id) + } + + const levelClone = cloneLevelSubtree(nodes, 'level_1' as AnyNodeId) + const levelMounted = levelClone.clonedNodes.find( + (node) => node.type === 'roof' && node.support?.kind === 'roof', + )! + expect(levelMounted.type).toBe('roof') + if (levelMounted.type === 'roof' && levelMounted.support.kind === 'roof') { + expect(levelMounted.support.roofSegmentId).toBe(levelClone.idMap.get('rseg_host')) + } + }) +}) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index d68227f070..2bee88931a 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -103,6 +103,11 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { ) as string | undefined } + if (clonedNode.type === 'roof' && clonedNode.support?.kind === 'roof') { + clonedNode.support.roofSegmentId = (idMap.get(clonedNode.support.roofSegmentId) ?? + clonedNode.support.roofSegmentId) as typeof clonedNode.support.roofSegmentId + } + // Remap supportSlabId (persisted slab-support hosts). The 'ground' // sentinel is not a node id — keep it as-is. if ( @@ -294,6 +299,11 @@ export function cloneLevelSubtree( idMap.get(cloned.hostRoofSegmentId) ?? cloned.hostRoofSegmentId } + if (cloned.type === 'roof' && cloned.support?.kind === 'roof') { + cloned.support.roofSegmentId = (idMap.get(cloned.support.roofSegmentId) ?? + cloned.support.roofSegmentId) as typeof cloned.support.roofSegmentId + } + // Remap supportSlabId when the host slab is inside the cloned subtree; // preserve it otherwise (like wallId, the reference may point outside). if ('supportSlabId' in cloned && typeof cloned.supportSlabId === 'string') { diff --git a/packages/editor/src/components/editor/handles/resize-snap.test.ts b/packages/editor/src/components/editor/handles/resize-snap.test.ts index eb9abac4ba..572ab436cc 100644 --- a/packages/editor/src/components/editor/handles/resize-snap.test.ts +++ b/packages/editor/src/components/editor/handles/resize-snap.test.ts @@ -50,6 +50,39 @@ describe('resolveResizeSnapValue', () => { expect(magneticSnap).not.toHaveBeenCalled() }) + it('applies a structural connection snap independently of the active mode', () => { + const connectionSnap = mock(() => 0.6) + + expect( + resolveResizeSnapValue({ + rawValue: 0.59, + gridSnapEnabled: false, + gridSnapActive: false, + gridSnapStep: 0.1, + magneticSnapActive: false, + connectionSnap, + }), + ).toBe(0.6) + expect(connectionSnap).toHaveBeenCalledWith(0.59) + }) + + it('bypasses a structural connection snap while force-moving', () => { + const connectionSnap = mock(() => 0.6) + + expect( + resolveResizeSnapValue({ + rawValue: 0.59, + gridSnapEnabled: false, + gridSnapActive: false, + gridSnapStep: 0.1, + magneticSnapActive: false, + connectionSnapActive: false, + connectionSnap, + }), + ).toBe(0.59) + expect(connectionSnap).not.toHaveBeenCalled() + }) + it('keeps the last valid value when pointer projection is non-finite', () => { expect( resolveResizeSnapValue({ diff --git a/packages/editor/src/components/editor/handles/resize-snap.ts b/packages/editor/src/components/editor/handles/resize-snap.ts index 34c1d2f3eb..ab318dbac8 100644 --- a/packages/editor/src/components/editor/handles/resize-snap.ts +++ b/packages/editor/src/components/editor/handles/resize-snap.ts @@ -8,6 +8,8 @@ export function resolveResizeSnapValue({ gridSnapStep, magneticSnapActive, magneticSnap, + connectionSnapActive = true, + connectionSnap, }: { rawValue: number fallbackValue?: number @@ -16,12 +18,15 @@ export function resolveResizeSnapValue({ gridSnapStep: number magneticSnapActive: boolean magneticSnap?: (value: number) => number + connectionSnapActive?: boolean + connectionSnap?: (value: number) => number }): number { if (!Number.isFinite(rawValue)) return fallbackValue const gridValue = gridSnapEnabled && gridSnapActive && gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue - const resolved = magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue + const modeValue = magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue + const resolved = connectionSnapActive && connectionSnap ? connectionSnap(modeValue) : modeValue return Number.isFinite(resolved) ? resolved : fallbackValue } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 168e7c6a1e..3981429a0d 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -235,16 +235,15 @@ export function NodeArrowHandles() { typeof def.handles === 'function' ? def.handles(node as never, descriptorSceneApi) : (def.handles as HandleDescriptor[]) - // The whole-node move-cross gizmo is gone: moving is now click-to-move on - // the selected node body (see selection-manager). Drop both flavours — the - // `translate` ground cross (column/roof/shelf/spawn) and the `tap-action` - // `move-cross` (item/door/window/elevator/stair) — keep rotate/resize. - return all.filter( - (d) => - d.kind !== 'translate' && - !('shape' in d && d.shape === 'move-cross') && - (d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false), - ) + return all.filter((descriptor) => { + if (descriptor.kind === 'translate') return false + const visible = + 'visible' in descriptor + ? descriptor.visible?.(node as never, descriptorSceneApi) + : undefined + if ('shape' in descriptor && descriptor.shape === 'move-cross') return visible === true + return visible !== false + }) }, [node, def, descriptorSceneApi]) const shouldRender = @@ -293,18 +292,25 @@ function NodeArrowHandlesForNode({ descriptors: HandleDescriptor[] }) { const parentId = node.parentId ?? null - const grandparentId = useScene((state) => { - if (!parentId) return null - const parent = state.nodes[parentId as AnyNodeId] - return parent?.parentId ?? null - }) const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent') ? 'grandparent' : 'parent' + const portalTargetResolver = descriptors.find( + (descriptor) => descriptor.portalTarget !== undefined, + )?.portalTarget + const descriptorSceneApi = useMemo(() => createSceneApi(useScene), []) + // Portal target: the mesh we createPortal into. - const portalTargetId = portalMode === 'grandparent' ? grandparentId : parentId + const portalTargetId = useScene((state) => { + if (portalTargetResolver) { + return portalTargetResolver(node as never, descriptorSceneApi) ?? null + } + const parentId = node.parentId ?? null + if (!parentId || portalMode === 'parent') return parentId + return state.nodes[parentId as AnyNodeId]?.parentId ?? null + }) // Outer wrapper mirrors this mesh's local pose. For 'parent' mode the // outer IS the node (so handles + drag math both live in node-local). // For 'grandparent' the outer rides the parent and an inner group adds @@ -780,6 +786,10 @@ function LinearArrow({ magneticSnap: linearDescriptor?.magneticSnap ? (value) => linearDescriptor.magneticSnap?.(initialNode, value, sceneApi) ?? value : undefined, + connectionSnapActive: !moveEvent.altKey, + connectionSnap: linearDescriptor?.connectionSnap + ? (value) => linearDescriptor.connectionSnap?.(initialNode, value, sceneApi) ?? value + : undefined, }) const next = Math.min(maxBound, Math.max(minBound, snappedNext)) if (next !== lastTickValue) { diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 24b4e0794e..dbbe884cae 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -1,7 +1,7 @@ 'use client' import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core' -import { type MouseEvent as ReactMouseEvent, useCallback } from 'react' +import { type MouseEvent as ReactMouseEvent, useCallback, useEffect } from 'react' import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' @@ -125,6 +125,12 @@ export function useFloorplanBackgroundPlacement({ walls, worldGridSnap, }: UseFloorplanBackgroundPlacementArgs) { + const roofFootprintSource = useEditor((state) => state.toolDefaults.roof?.footprintSource) + + useEffect(() => { + if (isRoofBuildActive && roofFootprintSource !== 'draw') clearRoofPlacementDraft() + }, [clearRoofPlacementDraft, isRoofBuildActive, roofFootprintSource]) + const handleBackgroundPlacementClick = useCallback( ( planPoint: WallPlanPoint, @@ -188,6 +194,11 @@ export function useFloorplanBackgroundPlacement({ emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) + if (roofFootprintSource !== 'draw') { + clearRoofPlacementDraft() + return true + } + if (roofDraftStart) { clearRoofPlacementDraft() } else { @@ -392,6 +403,7 @@ export function useFloorplanBackgroundPlacement({ isZoneBuildActive, levelId, roofDraftStart, + roofFootprintSource, setCursorPoint, setFenceDraftEnd, setFenceDraftStart, diff --git a/packages/editor/src/components/tools/item/placement-math.test.ts b/packages/editor/src/components/tools/item/placement-math.test.ts index c1984e6593..5a7f368726 100644 --- a/packages/editor/src/components/tools/item/placement-math.test.ts +++ b/packages/editor/src/components/tools/item/placement-math.test.ts @@ -1,5 +1,15 @@ import { describe, expect, test } from 'bun:test' -import { getDetachedAttachmentPreviewLift, stripTransient } from './placement-math' +import { getDetachedAttachmentPreviewLift, steppedRotation, stripTransient } from './placement-math' + +describe('steppedRotation', () => { + test('rotates a placement clockwise to the next 45 degree increment', () => { + expect(steppedRotation(Math.PI / 15, 1)).toBeCloseTo(Math.PI / 4) + }) + + test('rotates a placement counter-clockwise to the previous 45 degree increment', () => { + expect(steppedRotation(Math.PI / 15, -1)).toBeCloseTo(-Math.PI / 4) + }) +}) describe('stripTransient', () => { test('removes placement-only metadata flags before commit', () => { diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 65e1c13119..ccc793d417 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -59,6 +59,7 @@ import useAlignmentGuides from '../../../store/use-alignment-guides' import useEditor, { isAlignmentGuideActive, isMagneticSnapActive } from '../../../store/use-editor' import useFacingPose from '../../../store/use-facing-pose' +import usePlacementPreview from '../../../store/use-placement-preview' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { createLineGeometry, @@ -2249,9 +2250,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const newRotationY = steppedRotation(currentRotation[1] ?? 0, rotationDir) draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]] - // Ref + cursor mesh + item mesh — no store update during drag + // Rotate the building-local cursor by the same delta as the host-local + // draft. This preserves the host's composed yaw for items resting on a + // table or shelf while still matching the draft exactly on the floor. if (cursorGroupRef.current) { - cursorGroupRef.current.rotation.y = newRotationY + cursorGroupRef.current.rotation.y += newRotationY - currentRotation[1] } const mesh = sceneRegistry.nodes.get(draft.id) if (mesh) mesh.rotation.y = newRotationY @@ -2315,24 +2318,40 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } - // Update live transform for 2D floorplan with post-snap position + // Keep both preview renderers on the rotated draft. The item renderer + // consumes live node overrides, while the floor-plan renderer consumes + // the live transform and placement-preview snapshot. + useLiveNodeOverrides.getState().set(draft.id, { rotation: draft.rotation }) const currentLive = useLiveTransforms.getState().get(draft.id) - if (currentLive) { - const livePosition: [number, number, number] = - surface === 'floor' - ? [draft.position[0], draft.position[1], draft.position[2]] - : cursorGroupRef.current - ? [ - cursorGroupRef.current.position.x, - cursorGroupRef.current.position.y, - cursorGroupRef.current.position.z, - ] - : [draft.position[0], draft.position[1], draft.position[2]] - useLiveTransforms.getState().set(draft.id, { - ...currentLive, - position: livePosition, - rotation: newRotationY, - }) + const livePosition: [number, number, number] = + surface === 'floor' + ? [draft.position[0], draft.position[1], draft.position[2]] + : cursorGroupRef.current + ? [ + cursorGroupRef.current.position.x, + cursorGroupRef.current.position.y, + cursorGroupRef.current.position.z, + ] + : [draft.position[0], draft.position[1], draft.position[2]] + useLiveTransforms.getState().set(draft.id, { + ...currentLive, + position: livePosition, + rotation: cursorGroupRef.current?.rotation.y ?? newRotationY, + }) + + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === draft.id) { + const parentNode = draft.parentId + ? (useScene.getState().nodes[draft.parentId as AnyNodeId] ?? null) + : null + placementPreview.set( + { + ...draft, + position: [...draft.position], + rotation: [...draft.rotation], + }, + parentNode, + ) } revalidate() @@ -2477,9 +2496,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (dragMode) window.removeEventListener('pointerup', onReleaseCommit) unsubDraftWatch() useAlignmentGuides.getState().clear() - // Clear live transform for any remaining draft + // Clear every live preview channel before restoring or deleting the draft. if (draftNode.current) { useLiveTransforms.getState().clear(draftNode.current.id) + useLiveNodeOverrides.getState().clearFields(draftNode.current.id, ['rotation']) } draftNode.destroy() useScene.temporal.getState().resume() diff --git a/packages/editor/src/components/tools/registry-tool-context.tsx b/packages/editor/src/components/tools/registry-tool-context.tsx index f66dac7724..a6d3867877 100644 --- a/packages/editor/src/components/tools/registry-tool-context.tsx +++ b/packages/editor/src/components/tools/registry-tool-context.tsx @@ -5,6 +5,7 @@ import { createContext, type ReactNode, useContext } from 'react' export type RegistryToolContextValue = { activeLevelId: LevelNode['id'] | null + isCameraDragging: () => boolean sceneApi: SceneApi selectNode: (nodeId: AnyNodeId) => void } diff --git a/packages/editor/src/components/tools/roof/roof-footprint.test.ts b/packages/editor/src/components/tools/roof/roof-footprint.test.ts new file mode 100644 index 0000000000..b215e33012 --- /dev/null +++ b/packages/editor/src/components/tools/roof/roof-footprint.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from 'bun:test' +import { emitter, LevelNode, type WallEvent, WallNode } from '@pascal-app/core' +import { + fitRoofFootprint, + parseRoofFootprintSource, + resolveRoofFootprintElevation, + resolveRoofWallTopWorldElevation, + resolveRoomRoofFootprint, + subscribeToConicalRoofWallClicks, +} from './roof-footprint' + +describe('roof footprint sources', () => { + test('normalizes footprint sources for the selected roof type', () => { + expect(parseRoofFootprintSource('room', 'conical')).toBe('walls') + expect(parseRoofFootprintSource('draw', 'conical')).toBe('draw') + expect(parseRoofFootprintSource('walls', 'hip')).toBe('room') + expect(parseRoofFootprintSource('draw', 'hip')).toBe('draw') + }) + + test('fits a rotated rectangular room', () => { + const target = fitRoofFootprint( + 'room-1', + [ + [0, 0], + [4, 4], + [2, 6], + [-2, 2], + ], + [], + ) + + expect(target?.rectangular).toBe(true) + expect(target?.width).toBeCloseTo(Math.sqrt(32)) + expect(target?.depth).toBeCloseTo(Math.sqrt(8)) + expect(target?.center[0]).toBeCloseTo(1) + expect(target?.center[1]).toBeCloseTo(3) + expect(target?.rotation).toBeCloseTo(-Math.PI / 4) + }) + + test('marks curved and irregular rooms as non-rectangular', () => { + const target = fitRoofFootprint( + 'room-2', + [ + [0, 0], + [4, 0], + [4, 2], + [2, 1], + [0, 2], + ], + [], + ) + expect(target?.rectangular).toBe(false) + }) + + test('resolves the enclosed room beneath the pointer', () => { + const walls = [ + WallNode.parse({ start: [0, 0], end: [4, 0] }), + WallNode.parse({ start: [4, 0], end: [4, 3] }), + WallNode.parse({ start: [4, 3], end: [0, 3] }), + WallNode.parse({ start: [0, 3], end: [0, 0] }), + ] + const level = LevelNode.parse({ children: walls.map((wall) => wall.id) }) + const nodes = Object.fromEntries([level, ...walls].map((node) => [node.id, node])) + + const target = resolveRoomRoofFootprint(level.id, nodes, [2, 1]) + + expect(target?.rectangular).toBe(true) + expect(target?.wallIds).toHaveLength(4) + expect(resolveRoomRoofFootprint(level.id, nodes, [8, 8])).toBeNull() + }) + + test('resolves a room on the level below the active roof level', () => { + const walls = [ + WallNode.parse({ start: [0, 0], end: [4, 0] }), + WallNode.parse({ start: [4, 0], end: [4, 3] }), + WallNode.parse({ start: [4, 3], end: [0, 3] }), + WallNode.parse({ start: [0, 3], end: [0, 0] }), + ] + const groundLevel = LevelNode.parse({ + children: walls.map((wall) => wall.id), + level: 0, + }) + const activeLevel = LevelNode.parse({ children: [], level: 1 }) + const nodes = Object.fromEntries( + [groundLevel, activeLevel, ...walls].map((node) => [node.id, node]), + ) + + const target = resolveRoomRoofFootprint(activeLevel.id, nodes, [2, 1]) + + expect(target?.wallIds).toHaveLength(4) + }) + + test('converts a lower-level room height into the active level frame', () => { + const groundLevel = LevelNode.parse({ children: [], height: 3, level: 0 }) + const activeLevel = LevelNode.parse({ children: [], height: 3, level: 1 }) + const wall = WallNode.parse({ + parentId: groundLevel.id, + start: [0, 0], + end: [4, 0], + height: 3, + }) + const nodes = Object.fromEntries( + [groundLevel, activeLevel, wall].map((node) => [node.id, node]), + ) + const target = fitRoofFootprint( + 'room-ground', + [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + [wall.id], + ) + + expect(target && resolveRoofFootprintElevation(activeLevel.id, target, nodes)).toBe(0) + }) + + test('keeps a lower-level curved wall hover ghost in the active level world frame', () => { + const groundLevel = LevelNode.parse({ children: [], height: 3, level: 0 }) + const activeLevel = LevelNode.parse({ children: [], height: 3, level: 1 }) + const wall = WallNode.parse({ + parentId: groundLevel.id, + start: [-2, 0], + end: [2, 0], + curveOffset: 2, + height: 3, + }) + const nodes = Object.fromEntries( + [groundLevel, activeLevel, wall].map((node) => [node.id, node]), + ) + + expect(resolveRoofWallTopWorldElevation(activeLevel.id, wall, nodes)).toBeCloseTo(3) + }) + + test('routes a curved wall click to conical wall placement', () => { + const wall = WallNode.parse({ start: [-2, 0], end: [2, 0], curveOffset: 2 }) + const level = LevelNode.parse({ children: [wall.id], level: 0 }) + const wallOnLevel = { ...wall, parentId: level.id } + const nodes = Object.fromEntries([level, wallOnLevel].map((node) => [node.id, node])) + const selected: string[] = [] + const previewed: Array = [] + let stopped = false + const unsubscribe = subscribeToConicalRoofWallClicks({ + footprintSource: 'walls', + currentLevelId: level.id, + nodes, + onPreview: (previewWall) => previewed.push(previewWall?.id ?? null), + onSelect: (selectedWall) => selected.push(selectedWall.id), + roofType: 'conical', + }) + + emitter.emit('wall:enter', { node: wallOnLevel } as WallEvent) + emitter.emit('wall:click', { + node: wallOnLevel, + stopPropagation: () => { + stopped = true + }, + } as WallEvent) + emitter.emit('wall:leave', { node: wallOnLevel } as WallEvent) + unsubscribe() + + expect(selected).toEqual([wall.id]) + expect(previewed).toEqual([wall.id, null]) + expect(stopped).toBe(true) + }) + + test('ignores curved walls more than one level below the active roof level', () => { + const wall = WallNode.parse({ + parentId: 'level_ground', + start: [-2, 0], + end: [2, 0], + curveOffset: 2, + }) + const groundLevel = LevelNode.parse({ id: 'level_ground', children: [wall.id], level: 0 }) + const middleLevel = LevelNode.parse({ id: 'level_middle', children: [], level: 1 }) + const activeLevel = LevelNode.parse({ id: 'level_active', children: [], level: 2 }) + const nodes = Object.fromEntries( + [groundLevel, middleLevel, activeLevel, wall].map((node) => [node.id, node]), + ) + const previewed: Array = [] + const selected: string[] = [] + const unsubscribe = subscribeToConicalRoofWallClicks({ + footprintSource: 'walls', + currentLevelId: activeLevel.id, + nodes, + onPreview: (previewWall) => previewed.push(previewWall?.id ?? null), + onSelect: (selectedWall) => selected.push(selectedWall.id), + roofType: 'conical', + }) + + emitter.emit('wall:enter', { node: wall } as WallEvent) + emitter.emit('wall:click', { node: wall, stopPropagation: () => {} } as WallEvent) + unsubscribe() + + expect(previewed).toEqual([]) + expect(selected).toEqual([]) + }) +}) diff --git a/packages/editor/src/components/tools/roof/roof-footprint.ts b/packages/editor/src/components/tools/roof/roof-footprint.ts new file mode 100644 index 0000000000..d979de6a7f --- /dev/null +++ b/packages/editor/src/components/tools/roof/roof-footprint.ts @@ -0,0 +1,249 @@ +import { + type AnyNode, + detectSpacesForLevel, + emitter, + getLevelBelow, + getLevelElevations, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, + isCurvedWall, + type LevelNode, + pointInPolygon2D, + type RoofType, + resolveLevelId, + type WallEvent, + type WallNode, +} from '@pascal-app/core' + +export type RoofFootprintSource = 'room' | 'walls' | 'draw' + +export type RoofFootprintTarget = { + id: string + polygon: Array<[number, number]> + wallIds: WallNode['id'][] + center: [number, number] + width: number + depth: number + rotation: number + rectangular: boolean +} + +export function isConicalRoofWallEligible( + targetLevelId: LevelNode['id'], + wall: WallNode, + nodes: Readonly>, +): boolean { + const completeNodes = nodes as Record + const sourceLevelId = resolveLevelId(wall, completeNodes) + if (!sourceLevelId) return false + if (sourceLevelId === targetLevelId) return true + return getLevelBelow(targetLevelId, completeNodes)?.id === sourceLevelId +} + +export function parseRoofFootprintSource(value: unknown, roofType: RoofType): RoofFootprintSource { + if (value === 'draw') return 'draw' + return roofType === 'conical' ? 'walls' : 'room' +} + +export function subscribeToConicalRoofWallClicks(options: { + footprintSource: RoofFootprintSource + currentLevelId: LevelNode['id'] | null + nodes: Readonly> + onPreview?: (wall: WallNode | null) => void + onSelect: (wall: WallNode) => void + roofType: RoofType +}): () => void { + if (!(options.roofType === 'conical' && options.footprintSource === 'walls')) return () => {} + + let previewedWallId: WallNode['id'] | null = null + const onWallHover = (event: WallEvent) => { + const wall = + isCurvedWall(event.node) && + options.currentLevelId && + isConicalRoofWallEligible(options.currentLevelId, event.node, options.nodes) + ? event.node + : null + const nextId = wall?.id ?? null + if (nextId === previewedWallId) return + previewedWallId = nextId + options.onPreview?.(wall) + } + const onWallLeave = (event: WallEvent) => { + if (event.node.id !== previewedWallId) return + previewedWallId = null + options.onPreview?.(null) + } + const onWallClick = (event: WallEvent) => { + if ( + !isCurvedWall(event.node) || + !options.currentLevelId || + !isConicalRoofWallEligible(options.currentLevelId, event.node, options.nodes) + ) { + return + } + event.stopPropagation() + options.onSelect(event.node) + } + emitter.on('wall:enter', onWallHover) + emitter.on('wall:move', onWallHover) + emitter.on('wall:leave', onWallLeave) + emitter.on('wall:click', onWallClick) + return () => { + emitter.off('wall:enter', onWallHover) + emitter.off('wall:move', onWallHover) + emitter.off('wall:leave', onWallLeave) + emitter.off('wall:click', onWallClick) + } +} + +function polygonArea(polygon: ReadonlyArray): number { + return Math.abs( + polygon.reduce((area, point, index) => { + const next = polygon[(index + 1) % polygon.length] + return next ? area + point[0] * next[1] - next[0] * point[1] : area + }, 0) / 2, + ) +} + +export function fitRoofFootprint( + id: string, + polygon: Array<[number, number]>, + wallIds: WallNode['id'][], +): RoofFootprintTarget | null { + if (polygon.length < 3) return null + + let best: + | { + center: [number, number] + width: number + depth: number + rotation: number + area: number + } + | undefined + + for (let index = 0; index < polygon.length; index++) { + const point = polygon[index] + const next = polygon[(index + 1) % polygon.length] + if (!(point && next)) continue + const rotation = Math.atan2(next[1] - point[1], next[0] - point[0]) + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + const rotated = polygon.map(([x, z]) => [x * cos + z * sin, -x * sin + z * cos] as const) + const xs = rotated.map(([x]) => x) + const zs = rotated.map(([, z]) => z) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minZ = Math.min(...zs) + const maxZ = Math.max(...zs) + const width = maxX - minX + const depth = maxZ - minZ + const area = width * depth + if (area <= 0 || (best && best.area <= area)) continue + const localCenterX = (minX + maxX) / 2 + const localCenterZ = (minZ + maxZ) / 2 + best = { + center: [localCenterX * cos - localCenterZ * sin, localCenterX * sin + localCenterZ * cos], + width, + depth, + rotation: -rotation, + area, + } + } + + if (!best) return null + return { + id, + polygon, + wallIds, + center: best.center, + width: best.width, + depth: best.depth, + rotation: best.rotation, + rectangular: polygonArea(polygon) / best.area >= 0.96, + } +} + +export function resolveRoomRoofFootprint( + levelId: LevelNode['id'], + nodes: Readonly>, + point: [number, number], +): RoofFootprintTarget | null { + const activeTarget = resolveRoomRoofFootprintOnLevel(levelId, nodes, point) + if (activeTarget) return activeTarget + const levelBelow = getLevelBelow(levelId, nodes as Record) + return levelBelow ? resolveRoomRoofFootprintOnLevel(levelBelow.id, nodes, point) : null +} + +export function resolveRoofFootprintElevation( + targetLevelId: LevelNode['id'], + target: RoofFootprintTarget, + nodes: Readonly>, +): number { + const completeNodes = nodes as Record + const elevations = getLevelElevations(completeNodes) + return Math.max( + 0, + ...target.wallIds.map((id) => { + const wall = nodes[id] + return wall?.type === 'wall' + ? resolveRoofWallTopElevation(targetLevelId, wall, completeNodes, elevations) + : 0 + }), + ) +} + +export function resolveRoofWallTopElevation( + targetLevelId: LevelNode['id'], + wall: WallNode, + nodes: Readonly>, + elevations = getLevelElevations(nodes as Record), +): number { + const completeNodes = nodes as Record + const sourceLevelY = elevations.get(resolveLevelId(wall, completeNodes))?.baseY ?? 0 + const targetLevelY = elevations.get(targetLevelId)?.baseY ?? 0 + return Math.max( + 0, + sourceLevelY + + getWallBaseElevationForNodes(wall, completeNodes) + + getWallEffectiveHeightForNodes(wall, completeNodes) - + targetLevelY, + ) +} + +/** + * World/building-local Y for a wall-top preview rendered outside a level node. + * + * Roof nodes are parented to a level, so their stored position is relative to + * that level's floor. The conical wall hover ghost is rendered directly in the + * building group instead, and therefore needs the active level's world base + * added back after resolving the level-relative placement. + */ +export function resolveRoofWallTopWorldElevation( + targetLevelId: LevelNode['id'], + wall: WallNode, + nodes: Readonly>, + elevations = getLevelElevations(nodes as Record), +): number { + return ( + (elevations.get(targetLevelId)?.baseY ?? 0) + + resolveRoofWallTopElevation(targetLevelId, wall, nodes, elevations) + ) +} + +function resolveRoomRoofFootprintOnLevel( + levelId: LevelNode['id'], + nodes: Readonly>, + point: [number, number], +): RoofFootprintTarget | null { + const level = nodes[levelId] + if (level?.type !== 'level') return null + const walls = level.children + .map((id) => nodes[id]) + .filter((node): node is WallNode => node?.type === 'wall') + const spaces = detectSpacesForLevel(levelId, walls) + .spaces.filter((space) => !space.isExterior && pointInPolygon2D(point, space.polygon)) + .sort((left, right) => polygonArea(left.polygon) - polygonArea(right.polygon)) + const space = spaces[0] + return space ? fitRoofFootprint(space.id, space.polygon, space.wallIds) : null +} diff --git a/packages/editor/src/components/tools/roof/roof-placement-mode.test.ts b/packages/editor/src/components/tools/roof/roof-placement-mode.test.ts new file mode 100644 index 0000000000..9fe5a4ca18 --- /dev/null +++ b/packages/editor/src/components/tools/roof/roof-placement-mode.test.ts @@ -0,0 +1,16 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import useRoofPlacementMode from './roof-placement-mode' + +describe('roof placement mode', () => { + beforeEach(() => useRoofPlacementMode.setState({ mode: 'auto' })) + + test('cycles through auto, ground, and roof placement', () => { + const state = useRoofPlacementMode.getState() + state.cycleMode() + expect(useRoofPlacementMode.getState().mode).toBe('ground') + useRoofPlacementMode.getState().cycleMode() + expect(useRoofPlacementMode.getState().mode).toBe('roof') + useRoofPlacementMode.getState().cycleMode() + expect(useRoofPlacementMode.getState().mode).toBe('auto') + }) +}) diff --git a/packages/editor/src/components/tools/roof/roof-placement-mode.ts b/packages/editor/src/components/tools/roof/roof-placement-mode.ts new file mode 100644 index 0000000000..5168e6f027 --- /dev/null +++ b/packages/editor/src/components/tools/roof/roof-placement-mode.ts @@ -0,0 +1,19 @@ +import type { RoofPlacementMode } from '@pascal-app/core' +import { create } from 'zustand' + +const MODES: RoofPlacementMode[] = ['auto', 'ground', 'roof'] + +type RoofPlacementModeState = { + mode: RoofPlacementMode + cycleMode: () => void +} + +const useRoofPlacementMode = create((set, get) => ({ + mode: 'auto', + cycleMode: () => { + const current = MODES.indexOf(get().mode) + set({ mode: MODES[(current + 1) % MODES.length] ?? 'auto' }) + }, +})) + +export default useRoofPlacementMode diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 5eec0908ea..192532cc6b 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -5,17 +5,24 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + getWallArcData, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, type LevelNode, RoofNode, + type RoofPlacementMode, RoofSegmentNode, + type RoofType, + RoofType as RoofTypeSchema, resolveBuildingForLevel, + resolveConicalRoofPlacement, sceneRegistry, useScene, type WallNode, wallSegmentAnchors, } from '@pascal-app/core' import { clearSurfacePlanSnapFeedback, resolveSurfacePlanPointSnap } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' +import { generateRoofSegmentGeometry, useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { @@ -33,6 +40,15 @@ import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview' import { CursorSphere } from '../shared/cursor-sphere' +import { + parseRoofFootprintSource, + type RoofFootprintTarget, + resolveRoofFootprintElevation, + resolveRoofWallTopWorldElevation, + resolveRoomRoofFootprint, + subscribeToConicalRoofWallClicks, +} from './roof-footprint' +import useRoofPlacementMode from './roof-placement-mode' const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_PITCH_DEG = 40 @@ -43,7 +59,12 @@ function resolveRoofDraftPlacement( footprintDepth: number, quarterTurn: boolean, parentRotation = 0, + roofType: RoofType = 'gable', ) { + if (roofType === 'conical') { + const diameter = Math.max(footprintWidth, footprintDepth) + return { width: diameter, depth: diameter, rotation: -parentRotation } + } return { width: quarterTurn ? footprintDepth : footprintWidth, depth: quarterTurn ? footprintWidth : footprintDepth, @@ -120,7 +141,8 @@ const commitRoofPlacement = ( corner2: [number, number, number], selectedIds: string[], quarterTurn: boolean, -): AnyNode['id'] => { + placementMode: RoofPlacementMode, +): AnyNode['id'] | null => { const { createNode, createNodes, nodes } = useScene.getState() // A placed roof preset seeds `toolDefaults.roof` with the flattened @@ -129,6 +151,8 @@ const commitRoofPlacement = ( // come from the drawn rectangle and always win; the segment carries the // shape/material params, the roof container picks up the materials. const defaults = useEditor.getState().toolDefaults.roof ?? {} + const parsedRoofType = RoofTypeSchema.safeParse(defaults.roofType) + const roofType = parsedRoofType.success ? parsedRoofType.data : 'gable' const centerX = (corner1[0] + corner2[0]) / 2 const centerZ = (corner1[2] + corner2[2]) / 2 @@ -136,6 +160,47 @@ const commitRoofPlacement = ( const footprintWidth = Math.max(Math.abs(corner2[0] - corner1[0]), 1) const footprintDepth = Math.max(Math.abs(corner2[2] - corner1[2]), 1) + if (roofType === 'conical') { + const diameter = Math.max(footprintWidth, footprintDepth) + const curbHeight = + typeof defaults.wallHeight === 'number' ? defaults.wallHeight : DEFAULT_WALL_HEIGHT + const resolved = resolveConicalRoofPlacement({ + nodes, + levelId, + center: [centerX, centerZ], + radius: diameter / 2, + curbHeight, + mode: placementMode, + }) + if (!resolved.valid) return null + + const roofCount = Object.values(nodes).filter((node) => node.type === 'roof').length + const segment = RoofSegmentNode.parse({ + pitch: DEFAULT_PITCH_DEG, + roofType: 'gable', + ...defaults, + width: diameter, + depth: diameter, + wallHeight: resolved.wallHeight, + position: [0, 0, 0], + rotation: 0, + }) + const roof = RoofNode.parse({ + ...defaults, + name: `Roof ${roofCount + 1}`, + position: resolved.position, + support: resolved.support, + children: [segment.id], + }) + + createNodes([ + { node: roof, parentId: levelId }, + { node: segment, parentId: roof.id }, + ]) + sfxEmitter.emit('sfx:structure-build') + return roof.id + } + // Determine if there is an active roof node we should add to let targetRoofId: RoofNode['id'] | null = null const selectedId = selectedIds[0] @@ -174,6 +239,7 @@ const commitRoofPlacement = ( footprintDepth, quarterTurn, targetRoof.rotation, + roofType, ) const segment = RoofSegmentNode.parse({ @@ -201,6 +267,7 @@ const commitRoofPlacement = ( footprintDepth, quarterTurn, roofRotation, + roofType, ) // Create the segment first (centered in its new parent) @@ -234,6 +301,47 @@ const commitRoofPlacement = ( return roof.id } +const commitRoofFootprint = ( + levelId: LevelNode['id'], + target: RoofFootprintTarget, + quarterTurn: boolean, +): AnyNode['id'] | null => { + if (!target.rectangular) return null + const { createNodes, nodes } = useScene.getState() + const defaults = useEditor.getState().toolDefaults.roof ?? {} + const parsedRoofType = RoofTypeSchema.safeParse(defaults.roofType) + const roofType = parsedRoofType.success ? parsedRoofType.data : 'gable' + if (roofType === 'conical') return null + const roofCount = Object.values(nodes).filter((node) => node.type === 'roof').length + const segment = RoofSegmentNode.parse({ + wallHeight: 0, + pitch: DEFAULT_PITCH_DEG, + roofType: 'gable', + ...defaults, + width: quarterTurn ? target.depth : target.width, + depth: quarterTurn ? target.width : target.depth, + position: [0, 0, 0], + rotation: quarterTurn ? Math.PI / 2 : 0, + }) + const roof = RoofNode.parse({ + ...defaults, + name: `Roof ${roofCount + 1}`, + position: [ + target.center[0], + resolveRoofFootprintElevation(levelId, target, nodes), + target.center[1], + ], + rotation: target.rotation, + children: [segment.id], + }) + createNodes([ + { node: roof, parentId: levelId }, + { node: segment, parentId: roof.id }, + ]) + sfxEmitter.emit('sfx:structure-build') + return roof.id +} + type PreviewState = { corner1: [number, number, number] | null cursorPosition: [number, number, number] @@ -245,6 +353,7 @@ function buildRoofGhostGeometry( depth: number, wallHeight: number, pitchDeg: number, + roofType: RoofType, ) { const safeWidth = Math.max(width, 0.1) const safeDepth = Math.max(depth, 0.1) @@ -252,6 +361,13 @@ function buildRoofGhostGeometry( const halfDepth = safeDepth / 2 const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth + if (roofType === 'conical') { + const roofHeight = Math.max(0.001, Math.tan((pitchDeg * Math.PI) / 180) * halfWidth) + const geometry = new THREE.ConeGeometry(halfWidth, roofHeight, 48) + geometry.translate(0, wallHeight + roofHeight / 2, 0) + return geometry + } + const vertices = [ // Front slope -halfWidth, @@ -324,13 +440,28 @@ function buildRoofGhostGeometry( return geometry } -function buildRoofGhostEdges(width: number, depth: number, wallHeight: number, pitchDeg: number) { +function buildRoofGhostEdges( + width: number, + depth: number, + wallHeight: number, + pitchDeg: number, + roofType: RoofType, +) { const safeWidth = Math.max(width, 0.1) const safeDepth = Math.max(depth, 0.1) const halfWidth = safeWidth / 2 const halfDepth = safeDepth / 2 const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth + if (roofType === 'conical') { + const roofHeight = Math.max(0.001, Math.tan((pitchDeg * Math.PI) / 180) * halfWidth) + const cone = new THREE.ConeGeometry(halfWidth, roofHeight, 48) + cone.translate(0, wallHeight + roofHeight / 2, 0) + const edges = new THREE.EdgesGeometry(cone, 10) + cone.dispose() + return edges + } + const vertices = [ // Base rectangle -halfWidth, @@ -402,6 +533,17 @@ export const RoofTool: React.FC = () => { const currentLevelId = useViewer((state) => state.selection.levelId) const selectedIds = useViewer((state) => state.selection.selectedIds) const setSelection = useViewer((state) => state.setSelection) + const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) + const roofDefaults = useEditor((state) => state.toolDefaults.roof) + const placementMode = useRoofPlacementMode((state) => state.mode) + const nodes = useScene((state) => state.nodes) + const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType) + const roofType = parsedRoofType.success ? parsedRoofType.data : 'gable' + const footprintSource = parseRoofFootprintSource(roofDefaults?.footprintSource, roofType) + const previewWallHeight = + typeof roofDefaults?.wallHeight === 'number' ? roofDefaults.wallHeight : DEFAULT_WALL_HEIGHT + const previewPitch = + typeof roofDefaults?.pitch === 'number' ? roofDefaults.pitch : DEFAULT_PITCH_DEG const selectedIdsRef = useRef(selectedIds) useEffect(() => { @@ -416,12 +558,21 @@ export const RoofTool: React.FC = () => { const previousGridPosRef = useRef<[number, number] | null>(null) const quarterTurnRef = useRef(false) const [quarterTurn, setQuarterTurn] = useState(false) + const [footprintTarget, setFootprintTarget] = useState(null) + const previewTargetIdRef = useRef(null) + const [previewedConicalWallId, setPreviewedConicalWallId] = useState(null) const [preview, setPreview] = useState({ corner1: null, cursorPosition: [0, 0, 0], levelY: 0, }) + useEffect(() => { + if (footprintSource === 'room') return + previewTargetIdRef.current = null + setFootprintTarget(null) + }, [footprintSource]) + useEffect(() => { if (!currentLevelId) return @@ -439,7 +590,7 @@ export const RoofTool: React.FC = () => { // bodies on the active level + floor below (raising the green beacon), // falling back to alignment guides, then to the world-grid snap. The same // path the slab/ceiling tools use, so the beacon and coloring match. The - // pipeline reads the snapping mode itself (Shift bypass, magnetic on/off), + // pipeline reads the active snapping mode (grid / lines / angles / off), // so this tool never inspects the flags. `levelId` is intentionally omitted // so the explicit floor-below `walls` aren't filtered back out. const resolveDraftPoint = (event: GridEvent): [number, number] => { @@ -462,19 +613,70 @@ export const RoofTool: React.FC = () => { }).point } + const updateFootprintPreview = (target: RoofFootprintTarget | null) => { + setFootprintTarget((previous) => (previous?.id === target?.id ? previous : target)) + if (previewTargetIdRef.current === (target?.id ?? null)) return + previewTargetIdRef.current = target?.id ?? null + setPreviewSelectedIds(target?.wallIds ?? []) + } + const updateOutline = ( corner1: [number, number, number], corner2: [number, number, number], ) => { - const gridY = corner1[1] + GRID_OFFSET + let gridY = corner1[1] + GRID_OFFSET + + if (roofType === 'conical') { + const centerX = (corner1[0] + corner2[0]) / 2 + const centerZ = (corner1[2] + corner2[2]) / 2 + const diameter = Math.max( + Math.abs(corner2[0] - corner1[0]), + Math.abs(corner2[2] - corner1[2]), + ) + const defaults = useEditor.getState().toolDefaults.roof + const curbHeight = + typeof defaults?.wallHeight === 'number' ? defaults.wallHeight : DEFAULT_WALL_HEIGHT + const placement = resolveConicalRoofPlacement({ + nodes: useScene.getState().nodes, + levelId: currentLevelId, + center: [centerX, centerZ], + radius: diameter / 2, + curbHeight, + mode: useRoofPlacementMode.getState().mode, + }) + if (placement.valid) { + gridY = + placement.position[1] + + (placement.kind === 'roof' ? placement.wallHeight - curbHeight : 0) + + GRID_OFFSET + } + } - const groundPoints = [ - new Vector3(corner1[0], gridY, corner1[2]), - new Vector3(corner2[0], gridY, corner1[2]), - new Vector3(corner2[0], gridY, corner2[2]), - new Vector3(corner1[0], gridY, corner2[2]), - new Vector3(corner1[0], gridY, corner1[2]), - ] + const groundPoints = + roofType === 'conical' + ? (() => { + const centerX = (corner1[0] + corner2[0]) / 2 + const centerZ = (corner1[2] + corner2[2]) / 2 + const diameter = Math.max( + Math.abs(corner2[0] - corner1[0]), + Math.abs(corner2[2] - corner1[2]), + ) + return Array.from({ length: 49 }, (_, index) => { + const angle = (index / 48) * Math.PI * 2 + return new Vector3( + centerX + Math.cos(angle) * (diameter / 2), + gridY, + centerZ + Math.sin(angle) * (diameter / 2), + ) + }) + })() + : [ + new Vector3(corner1[0], gridY, corner1[2]), + new Vector3(corner2[0], gridY, corner1[2]), + new Vector3(corner2[0], gridY, corner2[2]), + new Vector3(corner1[0], gridY, corner2[2]), + new Vector3(corner1[0], gridY, corner1[2]), + ] outlineRef.current.geometry.dispose() outlineRef.current.geometry = new BufferGeometry().setFromPoints(groundPoints) @@ -484,6 +686,21 @@ export const RoofTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return + if (footprintSource !== 'draw') { + const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] + if (footprintSource === 'room') { + updateFootprintPreview( + resolveRoomRoofFootprint(currentLevelId, useScene.getState().nodes, rawPoint), + ) + } + cursorRef.current.position.set( + rawPoint[0], + event.localPosition[1] + GRID_OFFSET, + rawPoint[1], + ) + return + } + const [gridX, gridZ] = resolveDraftPoint(event) const y = event.localPosition[1] @@ -520,6 +737,18 @@ export const RoofTool: React.FC = () => { const onGridClick = (event: GridEvent) => { if (!currentLevelId) return + if (footprintSource !== 'draw') { + if (footprintSource !== 'room') return + const target = resolveRoomRoofFootprint(currentLevelId, useScene.getState().nodes, [ + event.localPosition[0], + event.localPosition[2], + ]) + if (!target) return + const roofId = commitRoofFootprint(currentLevelId, target, quarterTurnRef.current) + if (roofId) setSelection({ selectedIds: [roofId] }) + return + } + const [gridX, gridZ] = resolveDraftPoint(event) const y = event.localPosition[1] @@ -530,8 +759,11 @@ export const RoofTool: React.FC = () => { [gridX, y, gridZ], selectedIdsRef.current, quarterTurnRef.current, + useRoofPlacementMode.getState().mode, ) + if (!roofId) return + setSelection({ selectedIds: [roofId as AnyNode['id']] }) corner1Ref.current = null @@ -565,6 +797,8 @@ export const RoofTool: React.FC = () => { setPreview((prev) => ({ ...prev, corner1: null })) } clearSurfacePlanSnapFeedback() + previewTargetIdRef.current = null + setPreviewSelectedIds([]) } const onKeyDown = (event: KeyboardEvent) => { @@ -575,6 +809,20 @@ export const RoofTool: React.FC = () => { ) { return } + if (roofType === 'conical') { + if ( + (event.key === 'p' || event.key === 'P') && + !event.repeat && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { + event.preventDefault() + useRoofPlacementMode.getState().cycleMode() + sfxEmitter.emit('sfx:grid-snap') + } + return + } if ( (event.key !== 'r' && event.key !== 'R') || event.repeat || @@ -596,14 +844,33 @@ export const RoofTool: React.FC = () => { emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) + const unsubscribeConicalRoofWallClicks = subscribeToConicalRoofWallClicks({ + footprintSource, + currentLevelId, + nodes, + onPreview: (wall) => { + setPreviewedConicalWallId(wall?.id ?? null) + setPreviewSelectedIds(wall ? [wall.id] : []) + }, + onSelect: (wall) => { + setPreviewedConicalWallId(null) + setPreviewSelectedIds([]) + setSelection({ selectedIds: [wall.id] }) + }, + roofType, + }) window.addEventListener('keydown', onKeyDown) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + unsubscribeConicalRoofWallClicks() window.removeEventListener('keydown', onKeyDown) clearSurfacePlanSnapFeedback() + previewTargetIdRef.current = null + setPreviewedConicalWallId(null) + setPreviewSelectedIds([]) corner1Ref.current = null const draftPreview = useFloorplanDraftPreview.getState() @@ -611,7 +878,7 @@ export const RoofTool: React.FC = () => { draftPreview.setRoofDraftEnd(null) draftPreview.setRoofDraftQuarterTurn(false) } - }, [currentLevelId, setSelection]) + }, [currentLevelId, footprintSource, nodes, roofType, setPreviewSelectedIds, setSelection]) const { corner1, cursorPosition, levelY } = preview @@ -624,35 +891,138 @@ export const RoofTool: React.FC = () => { return { length, width, centerX, centerZ } }, [corner1, cursorPosition]) + const resolvedPreviewDimensions = + footprintSource === 'draw' + ? previewDimensions + : footprintTarget + ? { + length: footprintTarget.width, + width: footprintTarget.depth, + centerX: footprintTarget.center[0], + centerZ: footprintTarget.center[1], + } + : null + + const conicalPlacement = useMemo(() => { + if (!(currentLevelId && previewDimensions && roofType === 'conical')) return null + return resolveConicalRoofPlacement({ + nodes, + levelId: currentLevelId, + center: [previewDimensions.centerX, previewDimensions.centerZ], + radius: Math.max(previewDimensions.length, previewDimensions.width) / 2, + curbHeight: previewWallHeight, + mode: placementMode, + }) + }, [currentLevelId, nodes, placementMode, previewDimensions, previewWallHeight, roofType]) + + const conicalWallGhost = useMemo(() => { + if (!(roofType === 'conical' && footprintSource === 'walls' && previewedConicalWallId)) + return null + const wall = nodes[previewedConicalWallId] + if (wall?.type !== 'wall') return null + const arc = getWallArcData(wall) + if (!arc) return null + const segment = RoofSegmentNode.parse({ + roofType: 'conical', + width: arc.radius * 2, + depth: arc.radius * 2, + wallHeight: 0, + pitch: DEFAULT_PITCH_DEG, + conicalStartAngle: arc.startAngle, + conicalSweepAngle: arc.delta, + conicalFullCircle: true, + }) + const geometry = generateRoofSegmentGeometry(segment) + return { + edges: new THREE.EdgesGeometry(geometry, 10), + geometry, + position: [ + arc.center.x, + currentLevelId + ? resolveRoofWallTopWorldElevation(currentLevelId, wall, nodes) + : getWallBaseElevationForNodes(wall, nodes) + getWallEffectiveHeightForNodes(wall, nodes), + arc.center.y, + ] as [number, number, number], + } + }, [currentLevelId, footprintSource, nodes, previewedConicalWallId, roofType]) + + const ghostWallHeight = + footprintSource !== 'draw' + ? 0 + : conicalPlacement?.valid === true + ? conicalPlacement.wallHeight + : previewWallHeight + const ghostBaseY = + footprintSource !== 'draw' && footprintTarget && currentLevelId + ? resolveRoofFootprintElevation(currentLevelId, footprintTarget, nodes) + : conicalPlacement?.valid === true + ? conicalPlacement.position[1] + : levelY + const ghostColor = + footprintSource !== 'draw' && + footprintTarget && + !footprintTarget.rectangular && + roofType !== 'conical' + ? '#ef4444' + : footprintSource !== 'draw' + ? '#22c55e' + : conicalPlacement?.valid === false + ? '#ef4444' + : conicalPlacement?.kind === 'roof' + ? '#22c55e' + : '#818cf8' + const roofGhostGeometry = useMemo(() => { - if (!previewDimensions) return null + if (!resolvedPreviewDimensions || (roofType === 'conical' && footprintSource !== 'draw')) + return null const placement = resolveRoofDraftPlacement( - previewDimensions.length, - previewDimensions.width, + resolvedPreviewDimensions.length, + resolvedPreviewDimensions.width, quarterTurn, + 0, + roofType, ) return buildRoofGhostGeometry( placement.width, placement.depth, - DEFAULT_WALL_HEIGHT, - DEFAULT_PITCH_DEG, + ghostWallHeight, + previewPitch, + roofType, ) - }, [previewDimensions, quarterTurn]) + }, [ + footprintSource, + ghostWallHeight, + previewPitch, + quarterTurn, + resolvedPreviewDimensions, + roofType, + ]) const roofGhostEdges = useMemo(() => { - if (!previewDimensions) return null + if (!resolvedPreviewDimensions || (roofType === 'conical' && footprintSource !== 'draw')) + return null const placement = resolveRoofDraftPlacement( - previewDimensions.length, - previewDimensions.width, + resolvedPreviewDimensions.length, + resolvedPreviewDimensions.width, quarterTurn, + 0, + roofType, ) return buildRoofGhostEdges( placement.width, placement.depth, - DEFAULT_WALL_HEIGHT, - DEFAULT_PITCH_DEG, + ghostWallHeight, + previewPitch, + roofType, ) - }, [previewDimensions, quarterTurn]) + }, [ + footprintSource, + ghostWallHeight, + previewPitch, + quarterTurn, + resolvedPreviewDimensions, + roofType, + ]) useEffect( () => () => { @@ -662,6 +1032,14 @@ export const RoofTool: React.FC = () => { [roofGhostEdges, roofGhostGeometry], ) + useEffect( + () => () => { + conicalWallGhost?.geometry.dispose() + conicalWallGhost?.edges.dispose() + }, + [conicalWallGhost], + ) + return ( @@ -694,37 +1072,79 @@ export const RoofTool: React.FC = () => { /> )} - {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && ( + {conicalWallGhost && ( - {roofGhostGeometry && ( - - - - )} - {roofGhostEdges && ( - - - - )} + + + + + + )} + + {resolvedPreviewDimensions && + resolvedPreviewDimensions.length > 0.1 && + resolvedPreviewDimensions.width > 0.1 && ( + + {roofGhostGeometry && ( + + + + )} + {roofGhostEdges && ( + + + + )} + + )} ) } diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts index 5b2fea0753..0ed0b57a95 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts @@ -12,7 +12,14 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera } from 'three' +import { + BoxGeometry, + Mesh, + MeshBasicMaterial, + OrthographicCamera, + PerspectiveCamera, + Vector3, +} from 'three' import { z } from 'zod' import useInteractionScope from '../../../store/use-interaction-scope' import { createWallOnCurrentLevel } from '../wall/wall-drafting' @@ -82,7 +89,7 @@ describe('resolvePointerSupportSurface node tops', () => { sceneRegistry.clear() }) - const addPluginPlatform = (z = 0) => { + const addPluginPlatform = (z = 0, size: [number, number, number] = [4, 2, 4]) => { useScene.setState((state) => ({ nodes: { ...state.nodes, @@ -96,13 +103,47 @@ describe('resolvePointerSupportSurface node tops', () => { } as unknown as AnyNode, }, })) - const platformMesh = new Mesh(new BoxGeometry(4, 2, 4), new MeshBasicMaterial()) + const platformMesh = new Mesh(new BoxGeometry(...size), new MeshBasicMaterial()) platformMesh.position.set(0, 1, z) platformMesh.updateMatrixWorld(true) sceneRegistry.nodes.set(PLATFORM_ID, platformMesh) sceneRegistry.byType[PLATFORM_KIND]!.add(PLATFORM_ID) } + test('keeps an off-center orthographic ray on the cursor line', () => { + const camera = new OrthographicCamera(-20, 20, 20, -20, -1000, 1000) + camera.position.set(10, 10, 10) + camera.lookAt(0, 0, 0) + camera.updateMatrixWorld(true) + + const direction = camera.getWorldDirection(new Vector3()) + const right = new Vector3(1, 0, 0).applyQuaternion(camera.quaternion) + const up = new Vector3(0, 1, 0).applyQuaternion(camera.quaternion) + const rayOrigin = camera.position.clone().addScaledVector(right, 3).addScaledVector(up, 2) + const groundDistance = -rayOrigin.y / direction.y + const worldHit = rayOrigin.clone().addScaledVector(direction, groundDistance) + const topDistance = (2 - rayOrigin.y) / direction.y + const expectedTop = rayOrigin.clone().addScaledVector(direction, topDistance) + + addPluginPlatform(expectedTop.z, [0.5, 2, 0.5]) + const platformMesh = sceneRegistry.nodes.get(PLATFORM_ID)! + platformMesh.position.set(expectedTop.x, 1, expectedTop.z) + platformMesh.updateMatrixWorld(true) + + const support = resolvePointerSupportSurface( + camera, + worldHit.toArray() as [number, number, number], + { + includeNodeTopSurfaces: true, + }, + ) + + expect(support?.sourceNodeId).toBe(PLATFORM_ID) + expect(support?.worldPoint?.[0]).toBeCloseTo(expectedTop.x) + expect(support?.worldPoint?.[1]).toBeCloseTo(expectedTop.y) + expect(support?.worldPoint?.[2]).toBeCloseTo(expectedTop.z) + }) + test('discovers a plugin-declared top surface without a kind-name list', () => { addPluginPlatform() diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index 9b10855b13..f51a62df04 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -86,7 +86,19 @@ export function resolvePointerSupportSurface( // The world ray, kept before the level conversion below: the terrain field is // world-space (site geometry, not level-local), so the march needs this frame. camera.getWorldPosition(worldRayOrigin) - worldRayDirection.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin) + const cameraToHit = hitScratch.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin) + if ((camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera) { + // For an orthographic camera every screen pixel has the same direction. The + // hit point is offset from the camera along the view plane, so using + // `camera.position -> hit` tilts the ray toward the screen centre and makes + // support surfaces drift away from the cursor off-axis. + camera.getWorldDirection(worldRayDirection).normalize() + worldRayOrigin + .set(worldHit[0], worldHit[1], worldHit[2]) + .addScaledVector(worldRayDirection, -cameraToHit.dot(worldRayDirection)) + } else { + worldRayDirection.copy(cameraToHit).normalize() + } originScratch.copy(worldRayOrigin) hitScratch.set(worldHit[0], worldHit[1], worldHit[2]) diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 4c3c01b592..cef17e5fef 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -150,6 +150,7 @@ export const ToolManager: React.FC = () => { const registryToolContext = useMemo( () => ({ activeLevelId: activeLevelId ?? null, + isCameraDragging: () => useViewer.getState().cameraDragging, sceneApi: registrySceneApi, selectNode: (nodeId: AnyNodeId) => setSelection({ selectedIds: [nodeId] }), }), @@ -275,7 +276,7 @@ export const ToolManager: React.FC = () => { } return ( - <> + {/* World-space tools: site boundary and building movement operate in world coordinates */} {showSiteBoundaryEditor && } {/* Terrain sculpting is a mode rather than a `tools[phase][tool]` entry — @@ -391,9 +392,7 @@ export const ToolManager: React.FC = () => { NodeDefinition with a tool contribution, mount it here. */} {(!movingNode || registryToolOwnsPlacement) && useRegistryTool && RegistryToolComponent && ( - - - + )} {!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && ( @@ -421,6 +420,6 @@ export const ToolManager: React.FC = () => { {/* "Magnetic" beacon at the active wall-draft snap point. */} - + ) } diff --git a/packages/editor/src/components/ui/action-menu/index.tsx b/packages/editor/src/components/ui/action-menu/index.tsx index d87bd74614..7944028895 100644 --- a/packages/editor/src/components/ui/action-menu/index.tsx +++ b/packages/editor/src/components/ui/action-menu/index.tsx @@ -1,10 +1,12 @@ 'use client' +import { useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { motion } from 'motion/react' import { TooltipProvider } from './../../../components/ui/primitives/tooltip' import { useIsMobile } from './../../../hooks/use-mobile' import { useReducedMotion } from './../../../hooks/use-reduced-motion' +import { shouldShowEditingControls } from './../../../lib/interaction/overlay-policy' import { cn } from './../../../lib/utils' import useEditor from './../../../store/use-editor' import { CameraActions } from './camera-actions' @@ -18,6 +20,7 @@ const MOBILE_BOTTOM_OFFSET = 24 export function ActionMenu({ className }: { className?: string }) { const isMobile = useIsMobile() + const readOnly = useScene((s) => s.readOnly) const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0) const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId)) const CONTEXTUAL_TABS = new Set(['ai', 'items', 'studio']) @@ -31,7 +34,14 @@ export function ActionMenu({ className }: { className?: string }) { // Also hide on Chat / Items / Studio tabs; those are contextual workflows // (composing / picking furniture / generating renders) where the build // menu is irrelevant. - if (hasSelectionOnMobile || hasReferenceOnMobile || isContextualPanelOnMobile) return null + if ( + !shouldShowEditingControls(readOnly) || + hasSelectionOnMobile || + hasReferenceOnMobile || + isContextualPanelOnMobile + ) { + return null + } const transition = reducedMotion ? { duration: 0 } diff --git a/packages/editor/src/components/ui/helpers/roof-helper.tsx b/packages/editor/src/components/ui/helpers/roof-helper.tsx index 3fc89249f8..7d22e96ca4 100644 --- a/packages/editor/src/components/ui/helpers/roof-helper.tsx +++ b/packages/editor/src/components/ui/helpers/roof-helper.tsx @@ -1,12 +1,50 @@ +import type { ToolHint } from '@pascal-app/core' import type { SnapContext } from '../../../lib/snapping-mode' +import useEditor from '../../../store/use-editor' +import useRoofPlacementMode from '../../tools/roof/roof-placement-mode' import { ContextualHelperPanel } from './contextual-helper-panel' +const placementHint: ToolHint = { + key: 'P', + label: 'Placement', + chip: { + subscribe: (onChange) => useRoofPlacementMode.subscribe(onChange), + value: () => useRoofPlacementMode.getState().mode, + cycle: () => useRoofPlacementMode.getState().cycleMode(), + labels: { + auto: 'Placement: Auto', + ground: 'Placement: Ground', + roof: 'Placement: Roof', + }, + icons: { + auto: 'lucide:scan-search', + ground: 'lucide:land-plot', + roof: 'lucide:house', + }, + tooltip: 'Placement surface - click or press P to cycle', + }, +} + export function RoofHelper({ snapContext }: { snapContext?: SnapContext | null }) { + const isConical = useEditor((state) => state.toolDefaults.roof?.roofType === 'conical') + const footprintSource = useEditor((state) => state.toolDefaults.roof?.footprintSource) + const placementLabel = + footprintSource === 'room' + ? 'Choose room' + : footprintSource === 'walls' + ? 'Select curved wall' + : isConical + ? 'Set diameter' + : 'Set corner' return ( s.selection.zoneId) const setSelection = useViewer((s) => s.setSelection) const selectedReferenceId = useEditor((s) => s.selectedReferenceId) + const readOnly = useScene((s) => s.readOnly) // Only subscribe to the *type* of the single-selected node — string primitive // so we don't re-render on unrelated scene mutations. const selectedNodeType = useScene((s) => { @@ -267,6 +269,8 @@ export function PanelManager({ } }, [hasAnySelection]) + if (!shouldShowEditingControls(readOnly)) return null + if (isMobile) { if (selectedReferenceId) { return } /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx index 3cc1f3542f..56e3140bba 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx @@ -2,10 +2,11 @@ import { type AnyNodeId, type DormerNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { memo, useCallback, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { SnapTargetIcon } from '../../../snap-target-badge' import useEditor from './../../../../../store/use-editor' import { InlineRenameInput } from './inline-rename-input' -import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' +import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' interface DormerTreeNodeProps { @@ -26,8 +27,12 @@ export const DormerTreeNode = memo(function DormerTreeNode({ isLast, }: DormerTreeNodeProps) { const [isEditing, setIsEditing] = useState(false) + const [expanded, setExpanded] = useState(true) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) const node = useScene((s) => s.nodes[nodeId] as DormerNode | undefined) + const children = useScene( + useShallow((s) => (s.nodes[nodeId] as DormerNode | undefined)?.children ?? []), + ) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) @@ -55,8 +60,8 @@ export const DormerTreeNode = memo(function DormerTreeNode({ } depth={depth} - expanded={false} - hasChildren={false} + expanded={expanded} + hasChildren={children.length > 0} icon={ focusTreeNode(nodeId)} onMouseEnter={() => setHoveredId(nodeId)} onMouseLeave={() => setHoveredId(null)} - onToggle={() => {}} - /> + onToggle={() => setExpanded((value) => !value)} + > + {children.map((childId, index) => ( + + ))} + ) }) diff --git a/packages/editor/src/hooks/use-keyboard.test.ts b/packages/editor/src/hooks/use-keyboard.test.ts index 5b1be16f1e..f66f6c956f 100644 --- a/packages/editor/src/hooks/use-keyboard.test.ts +++ b/packages/editor/src/hooks/use-keyboard.test.ts @@ -7,10 +7,13 @@ import { useScene, } from '@pascal-app/core' import { meshEditScope } from '../lib/interaction/scope' +import useEditor from '../store/use-editor' import useInteractionScope from '../store/use-interaction-scope' import { canCycleSnappingModeShortcut, canRunGlobalRotationShortcut, + isToolOwnedCanopyForm, + isToolOwnedRotation, runHistoryShortcut, } from './use-keyboard' @@ -42,9 +45,32 @@ beforeEach(() => { afterEach(() => { useInteractionScope.getState().end() + useEditor.setState({ mode: 'select', tool: null }) clearSceneHistory() }) +describe('rotation shortcut ownership', () => { + test('leaves R and T to the active item placement tool', () => { + useEditor.setState({ mode: 'build', tool: 'item' }) + + expect(isToolOwnedRotation()).toBe(true) + }) + + test('leaves R and T to the active lean-to placement tool', () => { + useEditor.setState({ mode: 'build', tool: 'lean-to-extension' }) + + expect(isToolOwnedRotation()).toBe(true) + }) + + test('leaves F to the active lean-to placement tool', () => { + useEditor.setState({ mode: 'build', tool: 'lean-to-extension' }) + + expect(isToolOwnedCanopyForm()).toBe(true) + useEditor.setState({ tool: 'wall' }) + expect(isToolOwnedCanopyForm()).toBe(false) + }) +}) + describe('history shortcuts during block editing', () => { test('reserves global rotation shortcuts for the active mesh editor', () => { expect(canRunGlobalRotationShortcut()).toBe(true) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 007127901a..d8a4ee8dce 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -171,6 +171,25 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => { return true } +export const isToolOwnedRotation = () => { + const editor = useEditor.getState() + const moving = getMovingNode() + if (moving?.type === 'door' || moving?.type === 'window' || moving?.type === 'item') return true + return ( + editor.mode === 'build' && + (editor.tool === 'door' || + editor.tool === 'window' || + editor.tool === 'roof' || + editor.tool === 'item' || + editor.tool === 'lean-to-extension') + ) +} + +export const isToolOwnedCanopyForm = () => { + const editor = useEditor.getState() + return editor.mode === 'build' && editor.tool === 'lean-to-extension' +} + export const canRunGlobalRotationShortcut = () => useInteractionScope.getState().scope.kind !== 'mesh-editing' @@ -190,18 +209,9 @@ export const useKeyboard = ({ } // True while an active placement tool owns R/T. Door/window tools flip the - // draft and the roof tool turns its draft axes, so the global - // selection-based handler must stand down to avoid double-firing. - const isToolOwnedRotation = () => { - const ed = useEditor.getState() - const moving = getMovingNode() - if (moving?.type === 'door' || moving?.type === 'window') return true - return ( - ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window' || ed.tool === 'roof') - ) - } - - // A clean-tap Shift cycles the snapping mode (and a clean-tap Ctrl the grid step) + // draft, item / lean-to placement rotates its draft, and the roof tool turns + // its draft axes. The global selection handler must stand down to avoid double-firing. + // Shift cycles the snapping mode (and a clean-tap Ctrl the grid step) // whenever there's an active snapping context — i.e. exactly when the HUD // shows a snapping chip. That single source covers wall/fence/item drafting, // every node move (including wall-hosted items + door/window openings, which @@ -362,6 +372,7 @@ export const useKeyboard = ({ useEditor.getState().setMode('select') } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) { if (isVersionPreviewMode) return + if (isToolOwnedCanopyForm()) return e.preventDefault() useEditor.getState().setPhase('furnish') useEditor.getState().setMode('build') @@ -494,9 +505,9 @@ export const useKeyboard = ({ // open/close toggle lives on E. Windows still use R to toggle // their open/closed state. // - // Skipped entirely while a door/window placement or roof draft is active: - // those tools own R, and the user can have a node selected at the same - // time. Without this guard both the draft and selection would rotate. + // Skipped while an item, door, window, or roof placement owns rotation. + // The user can still have a node selected during placement; without this + // guard both the draft and the selection would rotate. // // References (guide/scan) live in `selectedReferenceId`, not the viewer // selection — check them first, like the Delete arm below. diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 83f27b8b10..824081119b 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -362,6 +362,7 @@ export { type ElevationGuideSource, type ElevationSnapMatch, type ElevationSnapTarget, + publishResolvedElevationGuide, publishStructuralElevationGuide, resolveElevationSnapMatch, resolveStructuralElevationSnap, @@ -422,7 +423,10 @@ export { type FloorplanMode, isFloorplanToolAvailableInMode, } from './lib/floorplan/floorplan-mode' -export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' +export { + commitFreshPlacementSubtree, + createFreshPlacementSubtree, +} from './lib/fresh-planar-placement' export { exportSceneToGlb } from './lib/glb-export' export { getHistoryCommandState, diff --git a/packages/editor/src/lib/continuation.test.ts b/packages/editor/src/lib/continuation.test.ts new file mode 100644 index 0000000000..0e3cef5f92 --- /dev/null +++ b/packages/editor/src/lib/continuation.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from 'bun:test' +import { CONTINUATION_PROFILES, continuationContextOf, nextContinuation } from './continuation' + +describe('canopy continuation', () => { + test('maps the canopy tool to its own single and continuous profile', () => { + expect(continuationContextOf('lean-to-extension')).toBe('canopy') + expect(CONTINUATION_PROFILES.canopy.default).toBe('single') + expect(nextContinuation('canopy', 'single')).toBe('continuous') + expect(nextContinuation('canopy', 'continuous')).toBe('single') + }) +}) diff --git a/packages/editor/src/lib/continuation.ts b/packages/editor/src/lib/continuation.ts index d8535c8896..420aef590c 100644 --- a/packages/editor/src/lib/continuation.ts +++ b/packages/editor/src/lib/continuation.ts @@ -1,4 +1,4 @@ -export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet' +export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet' | 'canopy' export type ContinuationMode = string export const CONTINUATION_PROFILES: Record< @@ -42,6 +42,12 @@ export const CONTINUATION_PROFILES: Record< labels: { single: 'Single cabinet', continuous: 'Continuous run' }, icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' }, }, + canopy: { + options: ['single', 'continuous'], + default: 'single', + labels: { single: 'Single canopy', continuous: 'Continuous canopy' }, + icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' }, + }, } const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column']) @@ -60,5 +66,6 @@ export function continuationContextOf(kind: string): ContinuationContext | null if (kind === 'wall') return 'wall' if (kind === 'fence') return 'fence' if (kind === 'cabinet') return 'cabinet' + if (kind === 'lean-to-extension') return 'canopy' return POINT_KINDS.has(kind) ? 'point' : null } diff --git a/packages/editor/src/lib/elevation-guides.test.ts b/packages/editor/src/lib/elevation-guides.test.ts index c0192aaea0..2aaa832cfa 100644 --- a/packages/editor/src/lib/elevation-guides.test.ts +++ b/packages/editor/src/lib/elevation-guides.test.ts @@ -4,6 +4,7 @@ import useElevationGuides from '../store/use-elevation-guides' import { clearStructuralElevationGuide, collectElevationSnapTargets, + publishResolvedElevationGuide, publishStructuralElevationGuide, resolveElevationSnapMatch, resolveStructuralElevationSnap, @@ -104,4 +105,26 @@ describe('elevation guides', () => { publishStructuralElevationGuide(source, 0.7, nodes) expect(useElevationGuides.getState().guide).toBeNull() }) + + test('publishes an explicitly resolved neighboring datum', () => { + const { level } = structuralScene() + useElevationGuides.setState({ guide: null }) + + publishResolvedElevationGuide( + { nodeId: 'leanto_moving', levelId: level.id, anchor: [2, 1] }, + { + id: 'leanto_neighbor:high-edge', + elevation: 3.4, + anchor: [5, 1], + label: 'Neighbor shed edge', + }, + ) + + expect(useElevationGuides.getState().guide).toMatchObject({ + ownerId: 'leanto_moving', + elevation: 3.4, + direction: [1, 0], + label: 'Neighbor shed edge', + }) + }) }) diff --git a/packages/editor/src/lib/elevation-guides.ts b/packages/editor/src/lib/elevation-guides.ts index b828be9ba7..300f70b80e 100644 --- a/packages/editor/src/lib/elevation-guides.ts +++ b/packages/editor/src/lib/elevation-guides.ts @@ -245,8 +245,20 @@ export function publishStructuralElevationGuide( return } - const dx = match.target.anchor[0] - source.anchor[0] - const dz = match.target.anchor[1] - source.anchor[1] + publishResolvedElevationGuide(source, match.target) +} + +export function publishResolvedElevationGuide( + source: ElevationGuideSource, + target: ElevationSnapTarget, +): void { + if (!source.levelId) { + clearStructuralElevationGuide(source.nodeId) + return + } + + const dx = target.anchor[0] - source.anchor[0] + const dz = target.anchor[1] - source.anchor[1] const length = Math.hypot(dx, dz) const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0] @@ -255,8 +267,8 @@ export function publishStructuralElevationGuide( levelId: source.levelId, center: source.anchor, direction, - elevation: match.elevation, - label: match.target.label, + elevation: target.elevation, + label: target.label, }) } diff --git a/packages/editor/src/lib/interaction/overlay-policy.test.ts b/packages/editor/src/lib/interaction/overlay-policy.test.ts index 3dbbdb0d02..21ea58ab55 100644 --- a/packages/editor/src/lib/interaction/overlay-policy.test.ts +++ b/packages/editor/src/lib/interaction/overlay-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { AnyNode } from '@pascal-app/core' -import { resolveOverlayPolicy } from './overlay-policy' +import { resolveOverlayPolicy, shouldShowEditingControls } from './overlay-policy' import type { ActiveInteractionScope } from './scope' const mockNode = (id: string, type: string): AnyNode => ({ id, type }) as unknown as AnyNode @@ -51,3 +51,10 @@ describe('resolveOverlayPolicy', () => { } }) }) + +describe('shouldShowEditingControls', () => { + test('hides controls that can mutate a read-only scene', () => { + expect(shouldShowEditingControls(false)).toBe(true) + expect(shouldShowEditingControls(true)).toBe(false) + }) +}) diff --git a/packages/editor/src/lib/interaction/overlay-policy.ts b/packages/editor/src/lib/interaction/overlay-policy.ts index d3dfd39d5a..8a00a61822 100644 --- a/packages/editor/src/lib/interaction/overlay-policy.ts +++ b/packages/editor/src/lib/interaction/overlay-policy.ts @@ -57,3 +57,7 @@ const ACTIVE_POLICY: OverlayPolicy = { export function resolveOverlayPolicy(scope: InteractionScope): OverlayPolicy { return isActive(scope) ? ACTIVE_POLICY : IDLE_POLICY } + +export function shouldShowEditingControls(readOnly: boolean): boolean { + return !readOnly +} diff --git a/packages/editor/src/lib/print-roof-solids.test.ts b/packages/editor/src/lib/print-roof-solids.test.ts index c84b3ff632..0ffbc90a0a 100644 --- a/packages/editor/src/lib/print-roof-solids.test.ts +++ b/packages/editor/src/lib/print-roof-solids.test.ts @@ -3,14 +3,23 @@ import { DoorNode, RoofSegmentNode, type RoofType } from '@pascal-app/core' import * as THREE from 'three' import { buildPrintableRoofSegmentSolids } from './print-roof-solids' -const ROOF_TYPES: RoofType[] = ['gable', 'hip', 'shed', 'gambrel', 'mansard', 'flat', 'dutch'] +const ROOF_TYPES: RoofType[] = [ + 'gable', + 'hip', + 'shed', + 'gambrel', + 'mansard', + 'flat', + 'dutch', + 'conical', +] function fixture(roofType: RoofType, overrides: Partial = {}): RoofSegmentNode { return RoofSegmentNode.parse({ id: `rseg_print-${roofType}`, roofType, width: 4, - depth: 3, + depth: roofType === 'conical' ? 4 : 3, wallHeight: 0.5, pitch: 30, wallThickness: 0.15, diff --git a/packages/editor/src/lib/print-roof-solids.ts b/packages/editor/src/lib/print-roof-solids.ts index 74261d2cc0..44672ca0aa 100644 --- a/packages/editor/src/lib/print-roof-solids.ts +++ b/packages/editor/src/lib/print-roof-solids.ts @@ -1,5 +1,6 @@ import { type AnyNode, + getConicalRoofCoverage, getRoofModuleFaces, getRoofShapeInsets, getRoofShapeRatios, @@ -412,6 +413,7 @@ function getVolumeFaces( node: RoofSegmentNode, options: { widthExtension: number; verticalOffset: number; isVoid: boolean }, ): RoofFace[] { + const conicalCoverage = getConicalRoofCoverage(node) const { activeRh, tanTheta } = getSegmentSlopeFrame(node) const width = Math.max(0.01, node.width + options.widthExtension * 2) const depth = Math.max(0.01, node.depth + options.widthExtension * 2) @@ -437,10 +439,13 @@ function getVolumeFaces( tanTheta, shapeRatios, dutchTopRakeThickness: node.dutchTopRakeThickness, + conicalStartAngle: conicalCoverage.startAngle, + conicalSweepAngle: conicalCoverage.sweepAngle, }) } function getShingleOuterFaces(node: RoofSegmentNode): RoofFace[] { + const conicalCoverage = getConicalRoofCoverage(node) const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node) const shapeRatios = getRoofShapeRatios(node) const horizontalOverhang = node.overhang * cosTheta @@ -458,7 +463,7 @@ function getShingleOuterFaces(node: RoofSegmentNode): RoofFace[] { let depth = baseDepth let translateZ = 0 - if (['hip', 'mansard', 'dutch'].includes(node.roofType)) { + if (['hip', 'mansard', 'dutch', 'conical'].includes(node.roofType)) { width += shingleHorizontalThickness * 2 depth += shingleHorizontalThickness * 2 } else if (['gable', 'gambrel'].includes(node.roofType)) { @@ -498,6 +503,8 @@ function getShingleOuterFaces(node: RoofSegmentNode): RoofFace[] { tanTheta, shapeRatios, dutchTopRakeThickness: node.dutchTopRakeThickness, + conicalStartAngle: conicalCoverage.startAngle, + conicalSweepAngle: conicalCoverage.sweepAngle, }) if (translateZ === 0) return faces diff --git a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts index ca4600b900..e201c23210 100644 --- a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts +++ b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts @@ -11,7 +11,16 @@ import { compilePrintShellBaseline } from './print-shell-compiler-baseline' import { compileManifoldMeshData } from './print-shell-compiler-manifold-core' import { compileSemanticPrintShellWithManifold } from './print-shell-compiler-manifold-worker' -const ROOF_TYPES: RoofType[] = ['gable', 'hip', 'shed', 'gambrel', 'mansard', 'flat', 'dutch'] +const ROOF_TYPES: RoofType[] = [ + 'gable', + 'hip', + 'shed', + 'gambrel', + 'mansard', + 'flat', + 'dutch', + 'conical', +] function structuralBox(id: string, x: number): THREE.Group { const group = new THREE.Group() @@ -344,7 +353,7 @@ describe('print shell compiler baseline', () => { id: `rseg_print-shell-${roofType}`, roofType, width: 4, - depth: 3, + depth: roofType === 'conical' ? 4 : 3, wallHeight: 0.5, pitch: 30, wallThickness: 0.15, diff --git a/packages/editor/src/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts index cb692c5249..9b54c9797e 100644 --- a/packages/editor/src/lib/selection-routing.test.ts +++ b/packages/editor/src/lib/selection-routing.test.ts @@ -1,6 +1,15 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNode, emitter, nodeRegistry, registerNode } from '@pascal-app/core' +import { + type AnyNode, + BlockNode, + emitter, + nodeRegistry, + registerNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { z } from 'zod' +import useEditor from '../store/use-editor' import { emitCanvasNodeSelection, resolveCanvasSelectionNode, @@ -71,6 +80,64 @@ describe('emitCanvasNodeSelection', () => { emitter.off('selection:canvas-node-click', onSelection) expect(received).toEqual([node]) }) + + test('deletes an accepted floorplan node when Delete mode is active', () => { + const node = BlockNode.parse({ id: 'block_floorplan-delete-target' }) + const previousMode = useEditor.getState().mode + const previousScene = useScene.getState() + const previousSelection = useViewer.getState().selection + const received: AnyNode[] = [] + const listener = (selectedNode: AnyNode) => received.push(selectedNode) + + emitter.on('selection:canvas-node-click', listener) + + try { + useEditor.setState({ mode: 'delete' }) + useScene.setState({ + nodes: { [node.id]: node }, + rootNodeIds: [node.id], + readOnly: false, + }) + useViewer.getState().setSelection({ selectedIds: [node.id] }) + + emitCanvasNodeSelection(node) + + expect(useScene.getState().nodes[node.id]).toBeUndefined() + expect(useViewer.getState().selection.selectedIds).toEqual([]) + expect(received).toEqual([]) + } finally { + emitter.off('selection:canvas-node-click', listener) + useEditor.setState({ mode: previousMode }) + useScene.setState(previousScene) + useViewer.setState({ selection: previousSelection }) + } + }) + + test('preserves a floorplan node and its selection when the scene is read-only', () => { + const node = BlockNode.parse({ id: 'block_floorplan-read-only-target' }) + const previousMode = useEditor.getState().mode + const previousScene = useScene.getState() + const previousSelection = useViewer.getState().selection + + try { + useEditor.setState({ mode: 'delete' }) + useScene.setState({ + nodes: { [node.id]: node }, + rootNodeIds: [node.id], + readOnly: true, + }) + useViewer.getState().setSelection({ selectedIds: [node.id] }) + + emitCanvasNodeSelection(node) + + expect(useScene.getState().nodes[node.id]).toEqual(node) + expect(useViewer.getState().selection.selectedIds).toEqual([node.id]) + } finally { + useEditor.setState({ mode: previousMode }) + useScene.setState(previousScene) + useViewer.setState({ selection: previousSelection }) + } + }) }) describe('selectionModifiersFromEvent', () => { diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts index bb140c0725..de4e989255 100644 --- a/packages/editor/src/lib/selection-routing.ts +++ b/packages/editor/src/lib/selection-routing.ts @@ -1,10 +1,15 @@ import { type AnyNode, + type AnyNodeId, emitter, type ItemNode, nodeRegistry, resolveSelectionProxyId, + useScene, } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import useEditor from '../store/use-editor' +import { emitDeleteSFX } from './sfx-bus' export type SelectionModifierKeys = { meta: boolean @@ -20,6 +25,20 @@ export type NodeSelectionTarget = { } export function emitCanvasNodeSelection(node: AnyNode): void { + if (useEditor.getState().mode === 'delete') { + const scene = useScene.getState() + if (scene.readOnly) return + + emitDeleteSFX(node.type) + scene.deleteNode(node.id as AnyNodeId) + if (node.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [] }) + if (useViewer.getState().hoveredId === node.id) { + useViewer.setState({ hoveredId: null }) + } + return + } + emitter.emit('selection:canvas-node-click', node) } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 52ba85e977..04bf93936f 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -514,6 +514,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = fence: CONTINUATION_PROFILES.fence.default, point: CONTINUATION_PROFILES.point.default, cabinet: CONTINUATION_PROFILES.cabinet.default, + canopy: CONTINUATION_PROFILES.canopy.default, }, showReferenceFloor: false, referenceFloorOffset: 1, @@ -668,6 +669,9 @@ function normalizeContinuationByContext( cabinet: migrateContinuationMode(state?.continuationByContext?.cabinet, 'cabinet') ?? CONTINUATION_PROFILES.cabinet.default, + canopy: + migrateContinuationMode(state?.continuationByContext?.canopy, 'canopy') ?? + CONTINUATION_PROFILES.canopy.default, } } diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index 307d6f881c..b937fdf790 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -18,7 +18,16 @@ import { publishLiveSceneSnapshot } from './live-sync' import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas' -const ROOF_TYPES = ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'] as const +const ROOF_TYPES = [ + 'hip', + 'gable', + 'shed', + 'gambrel', + 'dutch', + 'mansard', + 'flat', + 'conical', +] as const const RAILING_MODES = ['none', 'left', 'right', 'both'] as const export const createStoryShellInput = { @@ -361,9 +370,16 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat materialPreset, name, }) => { + const effectiveWidth = roofType === 'conical' ? Math.max(width, depth) : width + const effectiveDepth = roofType === 'conical' ? effectiveWidth : depth // Peak height is derived from pitch + footprint + type; we still // need it to size the auto-generated roof level container below. - const peakHeight = getActiveRoofHeight({ roofType, pitch, width, depth }) + const peakHeight = getActiveRoofHeight({ + roofType, + pitch, + width: effectiveWidth, + depth: effectiveDepth, + }) const referenceLevel = assertNode(bridge, levelId, 'level') const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = [] let targetRoofLevelId = levelId as AnyNodeId @@ -397,8 +413,8 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat const segment = RoofSegmentNode.parse({ roofType, - width, - depth, + width: effectiveWidth, + depth: effectiveDepth, wallHeight, pitch, wallThickness, diff --git a/packages/nodes/src/column/parametrics.test.ts b/packages/nodes/src/column/parametrics.test.ts new file mode 100644 index 0000000000..ea677d553c --- /dev/null +++ b/packages/nodes/src/column/parametrics.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { ColumnNode, LeanToExtensionNode } from '@pascal-app/core' +import { columnParametrics } from './parametrics' + +describe('column deletion', () => { + test('records a deleted managed lean-to pillar on its canopy', () => { + const canopy = LeanToExtensionNode.parse({ id: 'leanto_delete_managed_post' }) + const pillar = ColumnNode.parse({ + id: 'column_delete_managed_post', + parentId: canopy.id, + metadata: { + managedByLeanTo: canopy.id, + leanToRole: 'post', + leanToPostIndex: 1, + leanToPostSide: 'high', + }, + }) + + const updates = columnParametrics.onDelete?.(pillar, { + [canopy.id]: canopy, + [pillar.id]: pillar, + }) + + expect(updates).toEqual([ + { + id: canopy.id, + data: { omittedPostSlots: [{ side: 'high', index: 1, layoutCount: 3 }] }, + }, + ]) + }) +}) diff --git a/packages/nodes/src/column/parametrics.ts b/packages/nodes/src/column/parametrics.ts index 340c58f6f9..5bbe257a94 100644 --- a/packages/nodes/src/column/parametrics.ts +++ b/packages/nodes/src/column/parametrics.ts @@ -1,4 +1,5 @@ import type { ParametricDescriptor } from '@pascal-app/core' +import { leanToPostOmissionPatchesOnDelete } from '../lean-to-extension/post-omissions' import type { ColumnNode } from './schema' /** @@ -10,6 +11,7 @@ import type { ColumnNode } from './schema' * full legacy panel — Stage E will replace it via `customPanel`. */ export const columnParametrics: ParametricDescriptor = { + onDelete: leanToPostOmissionPatchesOnDelete, groups: [ { label: 'Dimensions', diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 570ce29e06..a8aa67791d 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -259,6 +259,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // `resolveOpeningPlacement`). const collides = hasWallChildOverlap( lastValid.parentId, + useScene.getState().nodes, lastValid.position[0], lastValid.position[1], live.width, diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index ac6d4f343e..479ba7eed6 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -347,6 +347,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const valid = !hasWallChildOverlap( event.node.id, + useScene.getState().nodes, clampedX, clampedY, movingDoorNode.width, diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 9851baeaf4..ad36a56361 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -291,7 +291,15 @@ const DoorTool: React.FC = () => { applySnap, }) const { clampedX, clampedY } = clampToWall(wall, localX, width, height) - 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 } } diff --git a/packages/nodes/src/dormer/__tests__/geometry.test.ts b/packages/nodes/src/dormer/__tests__/geometry.test.ts index 4ba74f659f..9b73b75b8b 100644 --- a/packages/nodes/src/dormer/__tests__/geometry.test.ts +++ b/packages/nodes/src/dormer/__tests__/geometry.test.ts @@ -1,11 +1,15 @@ import { describe, expect, test } from 'bun:test' -import { getRoofSegmentSurfaceY, type RoofSegmentNode, type RoofType } from '@pascal-app/core' -import { getDormerExposedFaces } from '../csg-geometry' import { - buildDormerGhostGeometry, - dormerSupportsArch, - dormerSupportsCornerRadii, -} from '../geometry' + getDormerDefaultWindowFace, + getDormerExposedFaces, + getRoofSegmentSurfaceY, + type RoofSegmentNode, + type RoofType, + WindowNode, +} from '@pascal-app/core' +import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { buildDormerRoofCut, generateDormerGeometry } from '../csg-geometry' +import { buildDormerGhostGeometry } from '../geometry' import { DormerNode } from '../schema' describe('buildDormerGhostGeometry (placement preview)', () => { @@ -30,6 +34,46 @@ describe('buildDormerGhostGeometry (placement preview)', () => { expect(b.boundingBox!.max.y).toBeGreaterThan(a.boundingBox!.max.y) }) + test('shedHighSide flips the shed pitch direction', () => { + const backHigh = buildDormerGhostGeometry( + DormerNode.parse({ + roofType: 'shed', + shedHighSide: 'back', + width: 4, + depth: 3, + height: 1, + roofHeight: 1.2, + }), + ) + const frontHigh = buildDormerGhostGeometry( + DormerNode.parse({ + roofType: 'shed', + shedHighSide: 'front', + width: 4, + depth: 3, + height: 1, + roofHeight: 1.2, + }), + ) + + const edgeMaxY = (geometry: typeof backHigh, z: number) => { + const position = geometry.getAttribute('position') + let maxY = -Infinity + for (let index = 0; index < position.count; index++) { + if (Math.abs(position.getZ(index) - z) < 0.001) { + maxY = Math.max(maxY, position.getY(index)) + } + } + return maxY + } + + expect(edgeMaxY(backHigh, -1.5)).toBeGreaterThan(edgeMaxY(backHigh, 1.5)) + expect(edgeMaxY(frontHigh, 1.5)).toBeGreaterThan(edgeMaxY(frontHigh, -1.5)) + + backHigh.dispose() + frontHigh.dispose() + }) + test.each([ ['flat', 1], ['gable', 2], @@ -64,16 +108,104 @@ describe('buildDormerGhostGeometry (placement preview)', () => { }) }) -describe('windowShape predicates', () => { - test('dormerSupportsArch only when windowShape=arch', () => { - expect(dormerSupportsArch(DormerNode.parse({ windowShape: 'arch' }))).toBe(true) - expect(dormerSupportsArch(DormerNode.parse({ windowShape: 'rounded' }))).toBe(false) - expect(dormerSupportsArch(DormerNode.parse({ windowShape: 'rectangle' }))).toBe(false) +describe('buildDormerRoofCut', () => { + test('keeps the committed shed cut aligned with the configured high side', () => { + const makeCut = (shedHighSide: 'back' | 'front') => + buildDormerRoofCut( + DormerNode.parse({ + roofType: 'shed', + shedHighSide, + width: 4, + depth: 3, + height: 1, + roofHeight: 1.2, + }), + )! + const backHigh = makeCut('back') + const frontHigh = makeCut('front') + const edgeMaxY = (geometry: typeof backHigh, z: number) => { + const position = geometry.getAttribute('position') + let maxY = -Infinity + for (let index = 0; index < position.count; index++) { + if (Math.abs(position.getZ(index) - z) < 0.001) { + maxY = Math.max(maxY, position.getY(index)) + } + } + return maxY + } + + expect(edgeMaxY(backHigh, -1.45)).toBeGreaterThan(edgeMaxY(backHigh, 1.45)) + expect(edgeMaxY(frontHigh, 1.45)).toBeGreaterThan(edgeMaxY(frontHigh, -1.45)) + + backHigh.dispose() + frontHigh.dispose() }) - test('dormerSupportsCornerRadii only when windowShape=rounded', () => { - expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'rounded' }))).toBe(true) - expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'arch' }))).toBe(false) - expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'rectangle' }))).toBe(false) +}) + +describe('hosted window cuts', () => { + test('cuts the same off-center point on the right face where the hosted window renders', () => { + const dormer = DormerNode.parse({ + depth: 3, + height: 1, + id: 'dormer_test', + position: [0, 10, 0], + roofHeight: 1, + roofType: 'gable', + wallSkirtHeight: 2, + width: 4, + }) + const window = WindowNode.parse({ + dormerFace: 'right', + dormerId: dormer.id, + height: 1, + id: 'window_test', + parentId: dormer.id, + position: [0.5, -0.5, 0], + width: 1, + }) + const geometry = generateDormerGeometry(dormer, hostSegment(), [window]) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const mesh = new Mesh(geometry, material) + const raycaster = new Raycaster(new Vector3(10, -0.5, -0.5), new Vector3(-1, 0, 0)) + + const firstHit = raycaster.intersectObject(mesh)[0] + + expect(firstHit?.point.x).toBeLessThan(0) + geometry.dispose() + material.dispose() + }) + + test('cuts a hosted window through the upper slope of a shed side wall', () => { + const dormer = DormerNode.parse({ + depth: 4, + height: 1, + id: 'dormer_test', + position: [0, 10, 0], + 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, 1.5, 0], + width: 1, + }) + const geometry = generateDormerGeometry(dormer, hostSegment(), [window]) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const mesh = new Mesh(geometry, material) + const raycaster = new Raycaster(new Vector3(10, 1.5, -1), new Vector3(-1, 0, 0)) + + const firstHit = raycaster.intersectObject(mesh)[0] + + expect(firstHit?.point.x).toBeLessThan(0) + geometry.dispose() + material.dispose() }) }) @@ -146,4 +278,14 @@ describe('getDormerExposedFaces', () => { back: true, }) }) + + test('uses the exposed back face for the automatic hosted window', () => { + const seg = hostSegment() + expect(getDormerDefaultWindowFace(dormerAt(seg, 0, -1.5), seg)).toBe('back') + }) + + test('prefers the front face when both or neither face clears the host', () => { + const seg = hostSegment({ pitch: 10 }) + expect(getDormerDefaultWindowFace(dormerAt(seg, 0, 1.5), seg)).toBe('front') + }) }) diff --git a/packages/nodes/src/dormer/__tests__/schema.test.ts b/packages/nodes/src/dormer/__tests__/schema.test.ts index eb833c34e3..f860e2ce9e 100644 --- a/packages/nodes/src/dormer/__tests__/schema.test.ts +++ b/packages/nodes/src/dormer/__tests__/schema.test.ts @@ -11,6 +11,7 @@ describe('DormerNode schema', () => { expect(parsed.depth).toBe(1.55) expect(parsed.height).toBe(0) expect(parsed.roofType).toBe('gable') + expect(parsed.shedHighSide).toBe('back') expect(parsed.windowShape).toBe('rectangle') expect(parsed.windowSill).toBe(false) }) @@ -25,6 +26,10 @@ describe('DormerNode schema', () => { const parsed = DormerNode.parse({ windowCornerRadii: [0.1, 0.2, 0.3, 0.4] }) expect(parsed.windowCornerRadii).toEqual([0.1, 0.2, 0.3, 0.4]) }) + + test('shedHighSide round-trips the front-high option', () => { + expect(DormerNode.parse({ shedHighSide: 'front' }).shedHighSide).toBe('front') + }) }) describe('getEffectiveDormerSurfaceMaterial', () => { diff --git a/packages/nodes/src/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index 8b5d786119..b2a33aaa9c 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -1,13 +1,16 @@ import { type DormerNode, + dormerWallFacePointToDormer, + getDormerWallFaceFrame, getPitchFromActiveRoofHeight, - getRoofSegmentSurfaceY, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, + type WindowNode, } from '@pascal-app/core' import { ADDITION, Brush, + buildOpeningCutoutGeometry, computeGeometryBoundsTree, csgEvaluator, csgGeometry, @@ -20,7 +23,7 @@ import { SUBTRACTION, } from '@pascal-app/viewer' import * as THREE from 'three' -import { buildDormerShellGeometry } from './geometry' +import { buildDormerShellGeometry, getDormerBodyYaw } from './geometry' // Legacy default for the hung-wall (skirt) height. Used as a fallback // when `dormer.wallSkirtHeight` is undefined (e.g. old saved scenes). @@ -48,181 +51,19 @@ export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeo return buildDormerShellGeometry(dormer) } -export function createDormerArchShape(w: number, h: number, archHeight: number): THREE.Shape { - const hw = w / 2 - const hh = h / 2 - const clampedArch = Math.min(Math.max(archHeight, 0.01), Math.max(h, 0.01)) - const springY = hh - clampedArch - const segments = 32 - - const shape = new THREE.Shape() - shape.moveTo(-hw, -hh) - shape.lineTo(hw, -hh) - shape.lineTo(hw, springY) - for (let i = 1; i <= segments; i++) { - const x = hw + (-hw - hw) * (i / segments) - const t = Math.min(Math.abs(x) / hw, 1) - const y = springY + clampedArch * Math.sqrt(Math.max(1 - t * t, 0)) - shape.lineTo(x, y) - } - shape.lineTo(-hw, -hh) - shape.closePath() - return shape -} - -export function normalizeDormerCornerRadii( - radii: [number, number, number, number], - w: number, - h: number, -): [number, number, number, number] { - const r = radii.map((v) => Math.max(v, 0)) as [number, number, number, number] - const scale = Math.min( - 1, - Math.max(w, 0) / Math.max(r[0] + r[1], 1e-6), - Math.max(w, 0) / Math.max(r[3] + r[2], 1e-6), - Math.max(h, 0) / Math.max(r[0] + r[3], 1e-6), - Math.max(h, 0) / Math.max(r[1] + r[2], 1e-6), +function createHostedWindowCutGeometry(window: WindowNode): THREE.BufferGeometry { + const depth = 0.4 + return buildOpeningCutoutGeometry( + window, + { + left: -window.width / 2, + right: window.width / 2, + bottom: -window.height / 2, + top: window.height / 2, + }, + depth, + 0.05, ) - if (scale >= 1) return r - return r.map((v) => v * scale) as [number, number, number, number] -} - -export function createDormerRoundedShape( - w: number, - h: number, - radii: [number, number, number, number], -): THREE.Shape { - const hw = w / 2 - const hh = h / 2 - const [tl, tr, br, bl] = normalizeDormerCornerRadii(radii, w, h) - - const shape = new THREE.Shape() - shape.moveTo(-hw + bl, -hh) - shape.lineTo(hw - br, -hh) - if (br > 0) shape.absarc(hw - br, -hh + br, br, -Math.PI / 2, 0, false) - else shape.lineTo(hw, -hh) - shape.lineTo(hw, hh - tr) - if (tr > 0) shape.absarc(hw - tr, hh - tr, tr, 0, Math.PI / 2, false) - else shape.lineTo(hw, hh) - shape.lineTo(-hw + tl, hh) - if (tl > 0) shape.absarc(-hw + tl, hh - tl, tl, Math.PI / 2, Math.PI, false) - else shape.lineTo(-hw, hh) - shape.lineTo(-hw, -hh + bl) - if (bl > 0) shape.absarc(-hw + bl, -hh + bl, bl, Math.PI, (3 * Math.PI) / 2, false) - else shape.lineTo(-hw, -hh) - shape.closePath() - return shape -} - -function resolveDormerRadii( - dormer: DormerNode, - w: number, - h: number, -): [number, number, number, number] { - return normalizeDormerCornerRadii(dormer.windowCornerRadii, w, h) -} - -function createDormerWindowCutGeometry( - dormer: DormerNode, - w: number, - h: number, - depth: number, -): THREE.BufferGeometry { - const shape = dormer.windowShape ?? 'rectangle' - if (shape === 'arch') { - const s = createDormerArchShape(w, h, dormer.windowArchHeight ?? 0.35) - const geo = new THREE.ExtrudeGeometry(s, { depth, bevelEnabled: false, curveSegments: 24 }) - geo.translate(0, 0, -depth / 2) - return geo - } - if (shape === 'rounded') { - const radii = resolveDormerRadii(dormer, w, h) - const s = createDormerRoundedShape(w, h, radii) - const geo = new THREE.ExtrudeGeometry(s, { depth, bevelEnabled: false, curveSegments: 24 }) - geo.translate(0, 0, -depth / 2) - return geo - } - return new THREE.BoxGeometry(w, h, depth) -} - -// Exposure datum: a face shows its window when the window CENTER clears -// the host's structural surface line (≥ half the window visible). -// Gating on the window BOTTOM suppressed the default window on the -// default 40° roof (break-even ≈ 36.7° pitch) and across the whole -// lower-slope/overhang band. A partially buried window reads as a -// window meeting the roof line: the host shingle shell occludes the -// buried frame from outside (the dormer roof cut only clears the inner -// cavity, 5cm short of the gable face), and the glass panes span the -// full opening so the wall cut never reads as a see-through hole. The -// margin only absorbs float noise at the grazing boundary — suppress -// only when the window is truly unplaceable. -const WINDOW_CENTER_MIN_CLEARANCE = 0.01 - -/** - * Which gable faces of a dormer have a visible window opening. - * "front" = mesh-local +Z, "back" = mesh-local −Z (after the +π/2 yaw - * bake for non-shed roofs). - * - * Each face centre is lifted into segment-local X *and* Z (the yaw - * matters, and on hip hosts the end slopes fall along X) and compared - * against the host's canonical per-type surface line via - * `getRoofSegmentSurfaceY`, which extrapolates past the structural - * eave instead of plateauing at the wall top — a face hanging in free - * air past the eave keeps dropping. Gates both the CSG window-cut - * decision (`generateDormerGeometry`) and the live render - * (window-assembly.tsx). - */ -export function getDormerExposedFaces( - dormer: DormerNode, - hostSegment: RoofSegmentNode, -): { front: boolean; back: boolean } { - const halfDepth = dormer.depth / 2 - const dormerX = dormer.position[0] ?? 0 - const dormerY = dormer.position[1] ?? 0 - const dormerZ = dormer.position[2] ?? 0 - const rot = dormer.rotation ?? 0 - - // Gable-face centres in segment-local X/Z (accounts for dormer yaw). - const faceDX = halfDepth * Math.sin(rot) - const faceDZ = halfDepth * Math.cos(rot) - - // Window centre in segment-local Y. Mirrors `getDormerSkirtWindowDims` - // so both functions read the same window position: dormer-local Y=0 - // sits at `dormer.position[1]` and the window centre sits in the - // skirt at -(skirtH / 2) + windowOffsetY. - const skirtH = dormerSkirtHeight(dormer) - const windowCenterSegY = dormerY - skirtH / 2 + (dormer.windowOffsetY ?? 0) - - const clears = (faceX: number, faceZ: number): boolean => - windowCenterSegY - getRoofSegmentSurfaceY(hostSegment, faceX, faceZ) > - WINDOW_CENTER_MIN_CLEARANCE - - return { - front: clears(dormerX + faceDX, dormerZ + faceDZ), - back: clears(dormerX - faceDX, dormerZ - faceDZ), - } -} - -/** - * Computed dimensions for the window opening on a dormer's gable face. - * The skirt (the wall extension below the eave used for CSG-trim) is - * `DORMER_DROP_BELOW` tall, so the window sits within that band. - */ -export function getDormerSkirtWindowDims(dormer: DormerNode): { - width: number - height: number - centerY: number - offsetX: number -} { - const skirtH = dormerSkirtHeight(dormer) - const maxW = Math.max(dormer.width - 0.1, 0.1) - const maxH = Math.max(skirtH - 0.1, 0.1) - const width = Math.min(Math.max(dormer.windowWidth ?? 1.2, 0.1), maxW) - const height = Math.min(Math.max(dormer.windowHeight ?? 1.2, 0.1), maxH) - const offsetX = dormer.windowOffsetX ?? 0 - const offsetY = dormer.windowOffsetY ?? 0 - const centerY = -(skirtH / 2) + offsetY - return { width, height, centerY, offsetX } } /** @@ -235,9 +76,10 @@ export function getDormerSkirtWindowDims(dormer: DormerNode): { export function generateDormerGeometry( dormer: DormerNode, hostSegment: RoofSegmentNode, + hostedWindows: readonly WindowNode[] = [], ): THREE.BufferGeometry { const isShed = dormer.roofType === 'shed' - const yawBake = isShed ? 0 : Math.PI / 2 + const yawBake = getDormerBodyYaw(dormer) const segWidth = isShed ? dormer.width : dormer.depth const segDepth = isShed ? dormer.depth : dormer.width const skirt = dormerSkirtHeight(dormer) @@ -293,6 +135,9 @@ export function generateDormerGeometry( deckThickness: 0.04, overhang: 0.08, shingleThickness: 0.02, + managedByParent: false, + wallShell: 'auto', + shedInsetEndPanels: false, } const dormerBrushes = getRoofSegmentBrushes(virtualSegment) @@ -388,20 +233,14 @@ export function generateDormerGeometry( dormerSolid = trimmed } - // Cut window openings on exposed gable faces. - const exposed = getDormerExposedFaces(dormer, hostSegment) - const skirtWin = getDormerSkirtWindowDims(dormer) - const gableHalfZ = dormer.depth / 2 - const cutDepth = 0.4 - - const cutFace = (zSign: number) => { - const cutGeo = createDormerWindowCutGeometry( - dormer, - skirtWin.width, - skirtWin.height, - cutDepth, - ) - cutGeo.translate(skirtWin.offsetX, skirtWin.centerY, zSign * gableHalfZ) + // Cut hosted window openings in dormer-local face coordinates. + const cutWindow = (window: WindowNode) => { + const face = window.dormerFace ?? 'front' + const frame = getDormerWallFaceFrame(dormer, face) + const center = dormerWallFacePointToDormer(dormer, face, window.position) + const cutGeo = createHostedWindowCutGeometry(window) + cutGeo.rotateY(frame.yaw) + cutGeo.translate(center[0], center[1], center[2]) if (!cutGeo.getIndex()) { const posCount = cutGeo.getAttribute('position').count const idx = new Uint32Array(posCount) @@ -421,8 +260,7 @@ export function generateDormerGeometry( dormerSolid = result } - if (exposed.front) cutFace(+1) - if (exposed.back) cutFace(-1) + for (const window of hostedWindows) cutWindow(window) resultGeo = csgGeometry(dormerSolid) const resultMaterials = csgMaterials(dormerSolid) @@ -480,10 +318,10 @@ export function generateDormerGeometry( * Shapes per roof type: * - **flat**: a plain box (top flush with the eave; the * dormer body has no roof above wallH). - * - **shed**: trapezoid in YZ, extruded along X. Eave - * at z=+d/2 (y=wallH), peak at z=-d/2 - * (y=wallH+roofH) — matches the slope - * direction the dormer body uses. + * - **shed**: trapezoid in YZ, extruded along X. The + * base shape is high at z=-d/2; the caller + * flips it when the configured high side is + * the front. * - **gable / gambrel**: pentagon (rectangle + symmetric triangle) * in XY, extruded along Z. Ridge runs * along Z (mesh-Z = virtualSegment-X after @@ -762,10 +600,13 @@ export function buildDormerRoofCut(dormer: DormerNode): THREE.BufferGeometry | n // - gable / gambrel: pentagon (narrows along width axis) const geo = buildDormerCutShape(dormer.roofType, innerW, innerD, skirt, wallH, roofH) - // Yaw in the geometry's own (un-translated) frame so the cut aligns - // with the dormer's footprint after rotation. - if (Math.abs(dormer.rotation) > 1e-4) { - geo.rotateY(dormer.rotation) + // Yaw in the geometry's own (un-translated) frame so the cut follows + // both the shed pitch direction and the dormer's footprint rotation. + const shedDirectionYaw = + dormer.roofType === 'shed' && dormer.shedHighSide === 'front' ? Math.PI : 0 + const cutYaw = shedDirectionYaw + dormer.rotation + if (Math.abs(cutYaw) > 1e-4) { + geo.rotateY(cutYaw) } // Translate into segment-local. position[1] becomes the dormer's diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 31ae153a0b..aaef64f82c 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -1,14 +1,11 @@ import { type AnyNode, - type AnyNodeId, DormerNode as DormerNodeSchema, type DormerNode as DormerNodeType, type HandleDescriptor, type NodeDefinition, - type RoofSegmentNode as RoofSegmentNodeType, - type SceneApi, } from '@pascal-app/core' -import { buildDormerRoofCut, getDormerExposedFaces } from './csg-geometry' +import { buildDormerRoofCut } from './csg-geometry' import { buildDormerFloorplan } from './floorplan' import { dormerPaint } from './paint' import { dormerParametrics } from './parametrics' @@ -26,21 +23,6 @@ const MIN_ROOF_HEIGHT = 0 const MAX_ROOF_HEIGHT = 2 const MIN_SKIRT = 0.2 const MAX_SKIRT = 6 -// Window-handle constants. The window opening is parametric geometry -// on the dormer's +Z gable face; chevrons sit just outside its rim -// with a small forward Z offset so they pop in front of the wall plane -// instead of z-fighting with the frame bars. -const WINDOW_SIDE_HANDLE_OFFSET = 0.15 -const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15 -const WINDOW_FACE_Z_OFFSET = 0.05 -// The four window-edge arrows latch behind a cube at the window center; -// they stay hidden until the user clicks that cube to open the group. -const WINDOW_LATCH_GROUP = 'dormer-window' -// Lower clamp for window dims matches the geometry's internal clamp -// in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the -// dormer dimensions and are resolved per-handle via the function form -// of `max`. -const MIN_WINDOW_DIM = 0.1 // Clamp used for handle Y placement so side chevrons stay reachable on // dormers whose wall is flat (`height ≈ 0`). The dormer body is // `height + roofHeight` tall; if that collapses too, the side arrows @@ -85,8 +67,7 @@ function dormerWidthHandle(side: 'left' | 'right'): HandleDescriptor n.width, apply: (initial, newWidth) => { @@ -252,159 +233,6 @@ function dormerRotateHandle(): HandleDescriptor { } } -// Window-center Y in dormer-local frame. The schema stores -// `windowOffsetY` as the bottom-relative offset of the window center -// from the bottom of the skirt; the geometry then maps it to -// `centerY = -(skirtH / 2) + offsetY`. We mirror that here so handle -// placements line up with what the inspector + window-assembly use. -function getWindowCenterY(n: DormerNodeType): number { - return -(n.wallSkirtHeight / 2) + n.windowOffsetY -} - -// Sign of the dormer-local Z direction where the visible window face -// sits. The dormer renders the window on both +Z (front) and -Z (back) -// gable faces, but only whichever face actually pokes above the host -// roof slope is exposed — `getDormerExposedFaces` is the source of -// truth there. The in-world handles need to attach to that exposed -// face so the user is editing the window they can see; as the dormer -// drags across the ridge, the exposed face flips and the chevrons -// follow. -// -// Preference order when both faces are exposed (e.g. a tall gable that -// pokes above the roof on both ends): keep handles on +Z so the -// affordance stays put visually instead of flipping when the slope -// math grazes the threshold from the other side. When neither face is -// exposed (degenerate — wall buried on both sides), fall back to +Z so -// the placement still produces a valid vector; the chevrons are just -// not useful there. -function getExposedFaceZSign(n: DormerNodeType, sceneApi: SceneApi): 1 | -1 { - if (!n.roofSegmentId) return 1 - const segment = sceneApi.get(n.roofSegmentId as AnyNodeId) - if (!segment) return 1 - const exposed = getDormerExposedFaces(n, segment) - if (exposed.front) return 1 - if (exposed.back) return -1 - return 1 -} - -// Window-width chevron on the +X (right) or -X (left) edge of the -// opening. Asymmetric: dragging one arrow grows the window outward -// from its own edge while the opposite edge stays put. The framework -// only knows about the scalar `windowWidth`; we re-emit `windowOffsetX` -// in `apply` so the anchored edge stays at the same X in dormer-local. -// Placement sits on the dormer's +Z gable face, where the window opens. -function dormerWindowWidthHandle(side: 'left' | 'right'): HandleDescriptor { - const sign = side === 'right' ? 1 : -1 - return { - kind: 'linear-resize', - axis: 'x', - // Stand the blade up into the gable face so it reads flat-on like the - // top/bottom window-height arrows instead of edge-on. - faceNormal: true, - // Hidden until the user clicks the window-center latch cube. - latchGroup: WINDOW_LATCH_GROUP, - anchor: side === 'right' ? 'min' : 'max', - min: MIN_WINDOW_DIM, - // Cap at the dormer's window field — keep a 0.1m gap on each side - // to match the geometry's interior clamp (`maxW = width - 0.1`). - max: (n) => Math.max(MIN_WINDOW_DIM, n.width - 0.1), - currentValue: (n) => n.windowWidth, - apply: (initial, newWidth) => { - // Anchored edge stays fixed: anchor X = initial.windowOffsetX - - // sign * initial.windowWidth/2. New center = anchor + sign * - // newWidth/2 → new windowOffsetX. - const anchorX = initial.windowOffsetX - sign * (initial.windowWidth / 2) - const newOffsetX = anchorX + sign * (newWidth / 2) - return { - windowWidth: newWidth, - windowOffsetX: newOffsetX, - } - }, - placement: { - position: (n, sceneApi) => { - const faceSign = getExposedFaceZSign(n, sceneApi) - return [ - n.windowOffsetX + sign * (n.windowWidth / 2 + WINDOW_SIDE_HANDLE_OFFSET), - getWindowCenterY(n), - faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET), - ] - }, - // Left chevron points -X; right points +X. LinearArrow doesn't - // auto-orient axis 'x' — descriptor handles the flip. - rotationY: () => (side === 'right' ? 0 : Math.PI), - }, - } -} - -// Window-height chevron on the +Y (top) or -Y (bottom) edge of the -// opening. Same asymmetric pattern as the width handle, projected onto -// the Y axis. The schema stores the window's vertical position as -// `windowOffsetY` (distance from the BOTTOM of the skirt to the window -// CENTER), not as a centerY in dormer-local — so `apply` translates -// back through that mapping when it re-emits the offset. -function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor { - const sign = side === 'top' ? 1 : -1 - return { - kind: 'linear-resize', - axis: 'y', - // Hidden until the user clicks the window-center latch cube. - latchGroup: WINDOW_LATCH_GROUP, - // 'min' = bottom edge anchored (top arrow grows the top edge up). - // 'max' = top edge anchored (bottom arrow drops the bottom edge). - anchor: side === 'top' ? 'min' : 'max', - min: MIN_WINDOW_DIM, - // Cap at the skirt with a 0.1m interior margin — matches - // `maxH = skirtH - 0.1` from `getDormerSkirtWindowDims`. - max: (n) => Math.max(MIN_WINDOW_DIM, n.wallSkirtHeight - 0.1), - currentValue: (n) => n.windowHeight, - apply: (initial, newHeight) => { - // Compute the anchored edge in dormer-local Y, derive the new - // centerY, then map back to schema-form `windowOffsetY`. - const initialCenterY = -(initial.wallSkirtHeight / 2) + initial.windowOffsetY - const anchorY = initialCenterY - sign * (initial.windowHeight / 2) - const newCenterY = anchorY + sign * (newHeight / 2) - const newOffsetY = newCenterY + initial.wallSkirtHeight / 2 - return { - windowHeight: newHeight, - windowOffsetY: newOffsetY, - } - }, - placement: { - position: (n, sceneApi) => { - const faceSign = getExposedFaceZSign(n, sceneApi) - return [ - n.windowOffsetX, - getWindowCenterY(n) + sign * (n.windowHeight / 2 + WINDOW_HEIGHT_HANDLE_OFFSET), - faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET), - ] - }, - }, - } -} - -// Window-center latch cube. Sits at the window center on the exposed -// gable face; clicking it reveals / hides the four window edge arrows -// (width L/R + height top/bottom) tagged with `WINDOW_LATCH_GROUP`. -// Mirrors the duct-fitting selection cube but driven by the shared -// latch descriptor so the dense window cluster stays collapsed behind -// one grip until the user opts in. -function dormerWindowLatchHandle(): HandleDescriptor { - return { - kind: 'latch', - group: WINDOW_LATCH_GROUP, - placement: { - position: (n, sceneApi) => { - const faceSign = getExposedFaceZSign(n, sceneApi) - return [ - n.windowOffsetX, - getWindowCenterY(n), - faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET), - ] - }, - }, - } -} - const dormerHandles: HandleDescriptor[] = [ dormerWidthHandle('right'), dormerWidthHandle('left'), @@ -412,11 +240,6 @@ const dormerHandles: HandleDescriptor[] = [ dormerDepthHandle('back'), dormerWallHeightHandle(), dormerRotateHandle(), - dormerWindowLatchHandle(), - dormerWindowWidthHandle('right'), - dormerWindowWidthHandle('left'), - dormerWindowHeightHandle('top'), - dormerWindowHeightHandle('bottom'), // The wall-skirt (downward chevron), roof-height (peak chevron), and // the asymmetric front/back depth split stay out for now. Re-adding // any of them previously fired the "Color target has no @@ -425,31 +248,23 @@ const dormerHandles: HandleDescriptor[] = [ // extras — only reproducible while `portal: 'grandparent'` was set, // which we no longer rely on (RoofEditSystem reveals the wrapper // instead). The shapes themselves are valid; if the count budget - // turns out to also be sensitive without grandparent portal, drop - // the window handles first since the inspector covers them too. + // turns out to also be sensitive without grandparent portal, keep + // the current compact set. // dormerWallSkirtHandle(), // dormerRoofHeightHandle(), ] /** * Dormer — a small house-shaped protrusion sitting on top of a roof - * segment. The window opening is inlined into the dormer's schema - * (window* fields drive parametric geometry on the front face), not - * a hosted child node — so `relations.hosts` stays unset. + * segment. Windows are hosted child nodes; the legacy window* fields remain + * in the schema only so scene migration can preserve older dormers. * - * **Scope of this port — stub.** Schema is complete (every field from - * the archive, including the four per-surface material slots and the - * full window-opening field set). Geometry renders a simple house - * silhouette (box body + triangular gable roof) for all `roofType` - * variants — the archive's variant-specific dormer roof shapes, - * window opening + frame, sill, and the CSG trim where the dormer - * meets the host roof are deferred. Per-surface paints (`topMaterial`, - * `sideMaterial`, `wallMaterial`) resolve via the shared helper from - * core but only roof / wall surfaces are emitted by the stub geometry. + * The renderer cuts each hosted window from the dormer shell and mounts + * the regular WindowNode renderer in the selected wall-face frame. */ export const dormerDefinition: NodeDefinition = { kind: 'dormer', - schemaVersion: 1, + schemaVersion: 4, schema: DormerNode, category: 'structure', surfaceRole: 'roof', @@ -465,7 +280,9 @@ export const dormerDefinition: NodeDefinition = { capabilities: { selectable: { hitVolume: 'bbox' }, - duplicable: true, + // Dormers own their WindowNode children. Duplicate the complete subtree + // so the copied dormer never aliases windows from the source dormer. + duplicable: { subtree: true }, deletable: true, // Mounts on a roof segment via `roofSegmentId`. Dirty marks // cascade to the host segment's parent roof so its merged shell @@ -482,6 +299,11 @@ export const dormerDefinition: NodeDefinition = { paint: dormerPaint, }, + relations: { + hosts: ['window'], + cascadeDelete: 'descendants', + }, + affordanceTools: { // Drag-to-place tool for duplicate + move. Reuses the placement // ghost preview but seeds it from the moving (cloned) node so the @@ -516,7 +338,6 @@ export const dormerDefinition: NodeDefinition = { }, mcp: { - description: - 'A dormer on a roof segment. Box body + gable roof + inlined window opening. Geometry beyond the stub silhouette coming later.', + description: 'A dormer on a roof segment. Its windows are hosted WindowNode children.', }, } diff --git a/packages/nodes/src/dormer/floorplan.ts b/packages/nodes/src/dormer/floorplan.ts index a4abe3414f..de6b8d9c9a 100644 --- a/packages/nodes/src/dormer/floorplan.ts +++ b/packages/nodes/src/dormer/floorplan.ts @@ -24,9 +24,9 @@ import type { * * Per-type roof linework follows the dormer's own roof geometry * (`buildDormerCutShape` in csg-geometry.ts): gable ridge runs along Z, - * shed slopes high-at-back (−Z) to low-at-front (+Z), hip ridges along the - * longer axis. Gambrel falls back to gable; dutch/mansard to hip — the - * same fallbacks the 3D cut uses. + * shed arrows follow the configured high-to-low direction, and hip ridges + * run along the longer axis. Gambrel falls back to gable; dutch/mansard to + * hip — the same fallbacks the 3D cut uses. */ export function buildDormerFloorplan( node: DormerNode, @@ -134,10 +134,9 @@ export function buildDormerFloorplan( const type = node.roofType if (node.roofHeight > 0 && type !== 'flat') { if (type === 'shed') { - // Slopes from the high back (−Z) down to the low front (+Z); show a - // downslope arrow pointing toward the front. - const tail = toPlan(0, -hd * 0.55) - const head = toPlan(0, hd * 0.55) + const highZ = node.shedHighSide === 'front' ? hd * 0.55 : -hd * 0.55 + const tail = toPlan(0, highZ) + const head = toPlan(0, -highZ) const dx = head[0] - tail[0] const dy = head[1] - tail[1] const len = Math.hypot(dx, dy) || 1 @@ -198,16 +197,5 @@ export function buildDormerFloorplan( } } - // Window on the +Z (front) face — a line just inside the front edge, - // spanning the window width centred at its X offset. Marks the glazing - // and which way the dormer faces. - const ww = node.windowWidth ?? 0 - if (ww > 0.01) { - const halfWin = Math.min(ww, node.width) / 2 - const center = Math.max(-hw + halfWin, Math.min(hw - halfWin, node.windowOffsetX ?? 0)) - const inset = Math.min(hd * 0.2, 0.08) - line([center - halfWin, hd - inset], [center + halfWin, hd - inset], lineWidth) - } - return { kind: 'group', children } } diff --git a/packages/nodes/src/dormer/geometry.ts b/packages/nodes/src/dormer/geometry.ts index a13b290536..40ca77bb05 100644 --- a/packages/nodes/src/dormer/geometry.ts +++ b/packages/nodes/src/dormer/geometry.ts @@ -22,6 +22,11 @@ export const DORMER_PLACEMENT_SNAP_M = 0.05 */ export const DORMER_PLACEMENT_ROTATION_STEP = (15 * Math.PI) / 180 +export function getDormerBodyYaw(node: Pick): number { + if (node.roofType !== 'shed') return Math.PI / 2 + return node.shedHighSide === 'front' ? Math.PI : 0 +} + /** * Builds the lightweight placement and live-edit shell from the same * per-type face generator used by committed roof geometry. @@ -80,7 +85,8 @@ export function buildDormerShellGeometry(node: DormerNode): THREE.BufferGeometry geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) for (const group of materialGroups) geometry.addGroup(group.start, group.count, group.materialIndex) - if (!isShed) geometry.rotateY(Math.PI / 2) + const bodyYaw = getDormerBodyYaw(node) + if (bodyYaw !== 0) geometry.rotateY(bodyYaw) geometry.computeVertexNormals() return geometry } @@ -88,15 +94,3 @@ export function buildDormerShellGeometry(node: DormerNode): THREE.BufferGeometry export function buildDormerGhostGeometry(node: DormerNode): THREE.BufferGeometry { return buildDormerShellGeometry(node) } - -/** - * Inspector helper: which window-shape sub-controls to surface for the - * current dormer. - */ -export function dormerSupportsArch(node: DormerNode): boolean { - return node.windowShape === 'arch' -} - -export function dormerSupportsCornerRadii(node: DormerNode): boolean { - return node.windowShape === 'rounded' -} diff --git a/packages/nodes/src/dormer/index.ts b/packages/nodes/src/dormer/index.ts index d435315d05..71b2eea625 100644 --- a/packages/nodes/src/dormer/index.ts +++ b/packages/nodes/src/dormer/index.ts @@ -1,9 +1,5 @@ export { dormerDefinition } from './definition' -export { - buildDormerGhostGeometry, - dormerSupportsArch, - dormerSupportsCornerRadii, -} from './geometry' +export { buildDormerGhostGeometry } from './geometry' export type { DormerSurfaceMaterialRole, DormerSurfaceMaterialSpec, diff --git a/packages/nodes/src/dormer/move-tool.tsx b/packages/nodes/src/dormer/move-tool.tsx index caba8748fd..ddab264e97 100644 --- a/packages/nodes/src/dormer/move-tool.tsx +++ b/packages/nodes/src/dormer/move-tool.tsx @@ -8,7 +8,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { useEditor } from '@pascal-app/editor' +import { commitFreshPlacementSubtree, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo } from 'react' import { DormerPlacementGuides } from './placement-guides' @@ -67,7 +67,12 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { // Restore visibility + metadata if the move was cancelled. const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = prevVisible ?? true - if (!isNew) { + if (isNew) { + if (node.id && useScene.getState().nodes[node.id]) { + useScene.getState().deleteNode(node.id as AnyNodeId) + } + useScene.temporal.getState().resume() + } else { useScene.getState().updateNode(node.id as AnyNodeId, { metadata: originalMetadata, }) @@ -103,7 +108,27 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { return Object.keys(rest).length > 0 ? rest : undefined })() - if (isNew || !node.id) { + if (isNew && node.id) { + const committedId = commitFreshPlacementSubtree(node.id as AnyNodeId, { + roofSegmentId: hit.segment.id, + parentId: hit.segment.id, + position: [hit.localX, hit.localY, hit.localZ], + rotation, + metadata: cleanedMeta, + visible: true, + }) + if (!committedId) return + const committedNode = useScene.getState().nodes[committedId] as DormerNode | undefined + for (const childId of committedNode?.children ?? []) { + const child = useScene.getState().nodes[childId] + if (child?.type === 'window') { + useScene.getState().updateNode(childId, { dormerId: committedId }) + } + } + state.dirtyNodes.add(hit.segment.id as AnyNodeId) + state.dirtyNodes.add(committedId) + setSelection({ selectedIds: [committedId] }) + } else if (!node.id) { const { id: _id, ...rest } = node const committed = DormerNodeSchema.parse({ ...rest, diff --git a/packages/nodes/src/dormer/panel-window-section.tsx b/packages/nodes/src/dormer/panel-window-section.tsx deleted file mode 100644 index 3dcdb9f3a1..0000000000 --- a/packages/nodes/src/dormer/panel-window-section.tsx +++ /dev/null @@ -1,315 +0,0 @@ -'use client' - -import type { DormerNode } from '@pascal-app/core' -import { PanelSection, SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor' -import { useState } from 'react' - -type WindowShape = DormerNode['windowShape'] -type WindowRadiusMode = 'all' | 'individual' - -function maxSharedRadius(width: number, height: number): number { - return Math.max(0, Math.min(width / 2, height / 2)) -} - -/** - * The Window tab of the dormer inspector: Hung Wall, Opening, Shape - * (with rounded/arch sub-controls), Frame, Grid, Sill. Owns local UI - * state for the "All vs Individual" corner-radius view mode — derived - * from tuple uniformity by default. - */ -export function DormerWindowSection({ - node, - previewProp, - commitProp, - handleUpdate, -}: { - node: DormerNode - previewProp: (updates: Partial) => void - commitProp: (updates: Partial) => void - handleUpdate: (updates: Partial) => void -}) { - const [radiusViewMode, setRadiusViewMode] = useState('all') - - const windowShape: WindowShape = node.windowShape - const windowCornerRadii: [number, number, number, number] = [...node.windowCornerRadii] - const windowArchHeight = node.windowArchHeight - const maxRadius = Math.max(0.01, maxSharedRadius(node.windowWidth, node.windowHeight)) - - const tupleIsUniform = - windowCornerRadii[0] === windowCornerRadii[1] && - windowCornerRadii[1] === windowCornerRadii[2] && - windowCornerRadii[2] === windowCornerRadii[3] - const sharedRadius = windowCornerRadii[0] - - const setCornerRadius = (index: number, value: number, commit: boolean) => { - const next = [...windowCornerRadii] as [number, number, number, number] - next[index] = value - if (commit) commitProp({ windowCornerRadii: next }) - else previewProp({ windowCornerRadii: next }) - } - - const setAllCornerRadii = (value: number, commit: boolean) => { - const next: [number, number, number, number] = [value, value, value, value] - if (commit) commitProp({ windowCornerRadii: next }) - else previewProp({ windowCornerRadii: next }) - } - - return ( - <> - - previewProp({ wallSkirtHeight: v })} - onCommit={(v) => commitProp({ wallSkirtHeight: v })} - precision={2} - restoreOnCommit={false} - step={0.05} - unit="m" - value={Math.round(node.wallSkirtHeight * 100) / 100} - /> - - - - previewProp({ windowWidth: v })} - onCommit={(v) => commitProp({ windowWidth: v })} - precision={2} - restoreOnCommit={false} - step={0.05} - unit="m" - value={Math.round(node.windowWidth * 100) / 100} - /> - previewProp({ windowHeight: v })} - onCommit={(v) => commitProp({ windowHeight: v })} - precision={2} - restoreOnCommit={false} - step={0.05} - unit="m" - value={Math.round(node.windowHeight * 100) / 100} - /> - previewProp({ windowOffsetX: v })} - onCommit={(v) => commitProp({ windowOffsetX: v })} - precision={2} - restoreOnCommit={false} - step={0.05} - unit="m" - value={Math.round(node.windowOffsetX * 100) / 100} - /> - previewProp({ windowOffsetY: v })} - onCommit={(v) => commitProp({ windowOffsetY: v })} - precision={2} - restoreOnCommit={false} - step={0.05} - unit="m" - value={Math.round(node.windowOffsetY * 100) / 100} - /> - - - - - handleUpdate({ - windowShape: v as WindowShape, - ...(v === 'rounded' - ? { - windowCornerRadii: windowCornerRadii.map((r) => Math.min(r, maxRadius)) as [ - number, - number, - number, - number, - ], - } - : {}), - }) - } - options={[ - { value: 'rectangle', label: 'Rect' }, - { value: 'rounded', label: 'Rounded' }, - { value: 'arch', label: 'Arch' }, - ]} - value={windowShape} - /> - {windowShape === 'rounded' && ( -
- setRadiusViewMode(v as WindowRadiusMode)} - options={[ - { value: 'all', label: 'All' }, - { value: 'individual', label: 'Individual' }, - ]} - value={tupleIsUniform ? radiusViewMode : 'individual'} - /> - {tupleIsUniform && radiusViewMode === 'all' ? ( - setAllCornerRadii(v, false)} - onCommit={(v) => setAllCornerRadii(v, true)} - precision={2} - restoreOnCommit={false} - step={0.01} - unit="m" - value={Math.round(sharedRadius * 100) / 100} - /> - ) : ( - ( - [ - ['Top Left', 0], - ['Top Right', 1], - ['Bottom Right', 2], - ['Bottom Left', 3], - ] as const - ).map(([label, index]) => ( - setCornerRadius(index, v, false)} - onCommit={(v) => setCornerRadius(index, v, true)} - precision={2} - restoreOnCommit={false} - step={0.01} - unit="m" - value={Math.round((windowCornerRadii[index] ?? 0) * 100) / 100} - /> - )) - )} -
- )} - {windowShape === 'arch' && ( - previewProp({ windowArchHeight: v })} - onCommit={(v) => commitProp({ windowArchHeight: v })} - precision={2} - restoreOnCommit={false} - step={0.05} - unit="m" - value={Math.round(windowArchHeight * 100) / 100} - /> - )} -
- - - previewProp({ windowFrameThickness: v })} - onCommit={(v) => commitProp({ windowFrameThickness: v })} - precision={3} - restoreOnCommit={false} - step={0.005} - unit="m" - value={Math.round(node.windowFrameThickness * 1000) / 1000} - /> - previewProp({ windowFrameDepth: v })} - onCommit={(v) => commitProp({ windowFrameDepth: v })} - precision={3} - restoreOnCommit={false} - step={0.005} - unit="m" - value={Math.round(node.windowFrameDepth * 1000) / 1000} - /> - previewProp({ windowDividerThickness: v })} - onCommit={(v) => commitProp({ windowDividerThickness: v })} - precision={3} - restoreOnCommit={false} - step={0.002} - unit="m" - value={Math.round(node.windowDividerThickness * 1000) / 1000} - /> - - - - previewProp({ windowColumns: Math.max(1, Math.min(8, Math.round(v))) })} - onCommit={(v) => commitProp({ windowColumns: Math.max(1, Math.min(8, Math.round(v))) })} - precision={0} - restoreOnCommit={false} - step={1} - value={node.windowColumns} - /> - previewProp({ windowRows: Math.max(1, Math.min(8, Math.round(v))) })} - onCommit={(v) => commitProp({ windowRows: Math.max(1, Math.min(8, Math.round(v))) })} - precision={0} - restoreOnCommit={false} - step={1} - value={node.windowRows} - /> - - - - handleUpdate({ windowSill: checked })} - /> - {node.windowSill && ( -
- previewProp({ windowSillDepth: v })} - onCommit={(v) => commitProp({ windowSillDepth: v })} - precision={3} - restoreOnCommit={false} - step={0.01} - unit="m" - value={Math.round(node.windowSillDepth * 1000) / 1000} - /> - previewProp({ windowSillThickness: v })} - onCommit={(v) => commitProp({ windowSillThickness: v })} - precision={3} - restoreOnCommit={false} - step={0.005} - unit="m" - value={Math.round(node.windowSillThickness * 1000) / 1000} - /> -
- )} -
- - ) -} diff --git a/packages/nodes/src/dormer/panel-windows-section.tsx b/packages/nodes/src/dormer/panel-windows-section.tsx new file mode 100644 index 0000000000..e6300d329a --- /dev/null +++ b/packages/nodes/src/dormer/panel-windows-section.tsx @@ -0,0 +1,84 @@ +'use client' + +import type { WindowNode } from '@pascal-app/core' +import { ActionButton, PanelSection } from '@pascal-app/editor' +import { Move, Pencil, Plus } from 'lucide-react' + +export function DormerWindowsSection({ + windows, + canAdd, + onAdd, + onEdit, + onMove, +}: { + windows: WindowNode[] + canAdd: boolean + onAdd: () => void + onEdit: (window: WindowNode) => void + onMove: (window: WindowNode) => void +}) { + return ( + + {windows.length > 0 ? ( +
+ {windows.map((window, index) => ( +
+ + + +
+ ))} +
+ ) : ( +
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..837771586a 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!) @@ -45,6 +50,34 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { [storeNode, liveOverrides], ) + const childNodes = useScene( + useShallow((state) => + (node.children ?? []) + .map((childId) => state.nodes[childId as AnyNodeId]) + .filter((child): child is AnyNode => child !== undefined), + ), + ) + const hostedWindowNodes = useMemo( + () => childNodes.filter((child): child is WindowNode => child.type === 'window'), + [childNodes], + ) + const hostedWindowIds = useMemo( + () => hostedWindowNodes.map((window) => window.id), + [hostedWindowNodes], + ) + const liveWindowOverrides = useLiveNodeOverrides( + useShallow((state) => hostedWindowIds.map((windowId) => state.overrides.get(windowId))), + ) + const hostedWindows = useMemo( + () => + hostedWindowNodes.map((window, index) => { + const override = liveWindowOverrides[index] + return override ? ({ ...window, ...override } as WindowNode) : window + }), + [hostedWindowNodes, liveWindowOverrides], + ) + const hasLiveWindowPreview = liveWindowOverrides.some((override) => override !== undefined) + const segment = useScene((state) => node.roofSegmentId ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) @@ -99,30 +132,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 +153,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 +186,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/index.ts b/packages/nodes/src/index.ts index 44ab8f2406..ff5f9b3602 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -184,6 +184,6 @@ export { stairDefinition } from './stair' export { stairSegmentDefinition } from './stair-segment' export { structuralGridDefinition } from './structural-grid' export { turbineVentDefinition } from './turbine-vent' -export { wallDefinition } from './wall' +export { createConicalRoofSectorAboveWall, wallDefinition } from './wall' export { windowDefinition } from './window' export { zoneDefinition } from './zone' diff --git a/packages/nodes/src/lean-to-extension/assembly.test.ts b/packages/nodes/src/lean-to-extension/assembly.test.ts index 1ec83c15b7..5a2380218a 100644 --- a/packages/nodes/src/lean-to-extension/assembly.test.ts +++ b/packages/nodes/src/lean-to-extension/assembly.test.ts @@ -28,6 +28,7 @@ import { resolveLeanToPostGutterSetback, } from './assembly' import { resolveLeanToLayout, resolveLeanToWallPlacement } from './layout' +import { resolveLeanToFreestandingRunPlacement } from './placement' import { applyLeanToWallAutoSpan } from './roof-attachment' beforeEach(() => spatialGridManager.clear()) @@ -606,6 +607,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 = { @@ -625,4 +710,218 @@ describe('lean-to assembly', () => { leanTo.projection + leanTo.lowOverhang + 1e-6, ) }) + + test('composes a freestanding gable canopy with two eaves and two outer post rows', () => { + const leanTo = LeanToExtensionNode.parse({ + canopyForm: 'gable', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + postLayoutMode: 'count', + postCount: 3, + projection: 3, + lowOverhang: 0.25, + }) + const layout = resolveLeanToLayout(leanTo) + const assembly = createLeanToAssembly(leanTo) + + expect(assembly.segment.roofType).toBe('shed') + expect(assembly.oppositeSegment?.roofType).toBe('shed') + expect(assembly.segment.depth).toBeCloseTo(3.25) + expect(assembly.oppositeSegment?.depth).toBeCloseTo(3.25) + expect(assembly.segment.rotation).toBe(0) + expect(assembly.oppositeSegment?.rotation).toBeCloseTo(Math.PI) + expect(assembly.oppositeGutter).toBeDefined() + expect(assembly.oppositeDownspout?.gutterId).toBe(assembly.oppositeGutter?.id) + expect(assembly.gutter.position[2]).toBeCloseTo(1.625) + expect(assembly.oppositeGutter?.position[2]).toBeCloseTo(1.625) + expect(assembly.oppositeGutter?.parentId).toBe(assembly.oppositeSegment?.id) + expect(assembly.oppositeSegment?.children).toEqual([ + assembly.oppositeGutter?.id, + assembly.oppositeDownspout?.id, + ]) + expect(assembly.posts).toHaveLength(6) + expect( + assembly.posts + .filter((post) => managedLeanToPostSide(post) === 'high') + .map((post) => post.position[2]), + ).toEqual([layout.oppositeBeamZ, layout.oppositeBeamZ, layout.oppositeBeamZ]) + }) + + test('composes a butterfly canopy from two inward shed planes with one valley drain', () => { + const leanTo = LeanToExtensionNode.parse({ + canopyForm: 'butterfly', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + postLayoutMode: 'count', + postCount: 3, + projection: 3, + lowOverhang: 0.25, + }) + const layout = resolveLeanToLayout(leanTo) + const assembly = createLeanToAssembly(leanTo) + + expect(assembly.segment.roofType).toBe('shed') + expect(assembly.oppositeSegment?.roofType).toBe('shed') + expect(assembly.segment.rotation).toBeCloseTo(Math.PI) + expect(assembly.oppositeSegment?.rotation).toBe(0) + expect(assembly.roof.children).toHaveLength(2) + expect(assembly.oppositeGutter).toBeUndefined() + expect(assembly.oppositeDownspout).toBeUndefined() + const valleyWorldZ = + assembly.segment.position[2] + + Math.cos(assembly.segment.rotation) * assembly.gutter.position[2] + expect(valleyWorldZ).toBeCloseTo(0) + expect(assembly.posts).toHaveLength(6) + expect( + assembly.posts + .filter((post) => managedLeanToPostSide(post) === 'high') + .map((post) => post.position[2]), + ).toEqual([layout.oppositeBeamZ, layout.oppositeBeamZ, layout.oppositeBeamZ]) + }) + + test('miters both halves of joined gable roofs and joins both eaves', () => { + const level = LevelNode.parse({ id: 'level_joined_gables', level: 0 }) + const first = resolveLeanToFreestandingRunPlacement(level.id, [0, 0], [4, 0], false, 'gable')! + const second = resolveLeanToFreestandingRunPlacement(level.id, [4, 0], [4, 4], false, 'gable')! + const nodes = Object.fromEntries( + [level, first, second].map((node) => [node.id, node]), + ) as Record + const assembly = createLeanToAssembly(first, undefined, nodes) + const run = first.projection + first.lowOverhang + + expect(assembly.segment.trim.right).toBeCloseTo(first.rightOverhang) + expect(assembly.segment.trim.frontRightX).toBeCloseTo(run) + expect(assembly.segment.trim.frontRightZ).toBeCloseTo(run) + expect(assembly.segment.trim.backRightX).toBe(0) + expect(assembly.gutter.length).toBeCloseTo(first.span + first.leftOverhang - run) + expect(assembly.gutter.endCapRight).toBe(false) + expect(assembly.oppositeSegment?.width).toBeCloseTo(first.span + first.leftOverhang + run) + expect(assembly.oppositeSegment?.trim.backLeftX).toBeCloseTo(run) + expect(assembly.oppositeSegment?.trim.backLeftZ).toBeCloseTo(run) + expect(assembly.oppositeGutter?.length).toBeCloseTo(first.span + first.leftOverhang + run) + expect(assembly.oppositeGutter?.endCapLeft).toBe(false) + }) + + test('maps a joined butterfly cut onto the rotated roof plane and valley gutter', () => { + const level = LevelNode.parse({ id: 'level_joined_butterflies', level: 0 }) + const first = resolveLeanToFreestandingRunPlacement( + level.id, + [0, 0], + [4, 0], + false, + 'butterfly', + )! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + [4, 0], + [4, 4], + false, + 'butterfly', + )! + const nodes = Object.fromEntries( + [level, first, second].map((node) => [node.id, node]), + ) as Record + const assembly = createLeanToAssembly(first, undefined, nodes) + const run = first.projection + first.lowOverhang + + expect(assembly.segment.trim.left).toBeCloseTo(first.rightOverhang) + expect(assembly.segment.trim.backLeftX).toBeCloseTo(run) + expect(assembly.segment.trim.backLeftZ).toBeCloseTo(run) + expect(assembly.oppositeSegment?.width).toBeCloseTo(first.span + first.leftOverhang + run) + expect(assembly.oppositeSegment?.trim.frontRightX).toBeCloseTo(run) + expect(assembly.oppositeSegment?.trim.frontRightZ).toBeCloseTo(run) + expect(assembly.gutter.length).toBeCloseTo(first.span + first.leftOverhang) + expect(assembly.gutter.endCapLeft).toBe(false) + }) + + test.each([ + 'gable', + 'butterfly', + ] as const)('replaces duplicate %s corner posts with one shared post on each support row', (canopyForm) => { + const level = LevelNode.parse({ id: `level_${canopyForm}_shared_posts`, level: 0 }) + const first = resolveLeanToFreestandingRunPlacement( + level.id, + [0, 0], + [8, 0], + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + [8, 0], + [8, 8], + false, + canopyForm, + )! + const nodes = Object.fromEntries( + [level, first, second].map((node) => [node.id, node]), + ) as Record + const assemblies = [ + createLeanToAssembly(first, undefined, nodes), + createLeanToAssembly(second, undefined, nodes), + ] + const posts = assemblies.flatMap((assembly) => assembly.posts) + const sharedPosts = posts.filter((post) => { + const index = managedLeanToPostIndex(post) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }) + + expect(posts).toHaveLength(resolveLeanToLayout(first).postXs.length * 4 - 2) + expect(sharedPosts).toHaveLength(2) + expect(sharedPosts.map(managedLeanToPostSide).sort()).toEqual(['high', 'low']) + }) + + test('joins every valid freestanding direction for every canopy form', () => { + for (const canopyForm of ['mono', 'gable', 'butterfly'] as const) { + for (const turnDirection of [-1, 1] as const) { + for (const turnDegrees of [0, 5, 15, 25, 45, 90, 135, 155, 165, 175]) { + const level = LevelNode.parse({ + id: `level_${canopyForm}_${turnDirection}_${turnDegrees}`, + level: 0, + }) + const radians = (turnDirection * turnDegrees * Math.PI) / 180 + const joint: [number, number] = [100, 0] + const end: [number, number] = [ + joint[0] + 100 * Math.cos(radians), + joint[1] + 100 * Math.sin(radians), + ] + const first = resolveLeanToFreestandingRunPlacement( + level.id, + [0, 0], + joint, + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + joint, + end, + false, + canopyForm, + )! + const nodes = Object.fromEntries( + [level, first, second].map((node) => [node.id, node]), + ) as Record + const firstAssembly = createLeanToAssembly(first, undefined, nodes) + const secondAssembly = createLeanToAssembly(second, undefined, nodes) + + if (canopyForm === 'butterfly') { + expect(firstAssembly.gutter.endCapLeft).toBe(false) + expect(secondAssembly.gutter.endCapRight).toBe(false) + } else { + expect(firstAssembly.gutter.endCapRight).toBe(false) + expect(secondAssembly.gutter.endCapLeft).toBe(false) + } + if (canopyForm === 'mono' && turnDegrees === 0) { + expect(firstAssembly.segment.trim.right).toBeCloseTo(first.rightOverhang) + expect(secondAssembly.segment.trim.left).toBeCloseTo(second.leftOverhang) + } + if (canopyForm === 'gable') { + expect(firstAssembly.oppositeGutter?.endCapLeft).toBe(false) + expect(secondAssembly.oppositeGutter?.endCapRight).toBe(false) + } + } + } + } + }) }) diff --git a/packages/nodes/src/lean-to-extension/assembly.ts b/packages/nodes/src/lean-to-extension/assembly.ts index 674befd216..0a3e8f8de8 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,21 @@ 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 { + canopyCornerJointMetadata, + FREESTANDING_CANOPY_JOINTS_KEY, + type FreestandingCanopyJoint, + resolveCanopyGutterJointLayout, + resolveCanopyRoofPlaneJointLayout, + resolveFreestandingCanopyJoints, +} from './canopy-joint' +import { isClosedLoopLeanTo } from './conical-host' import { applyLeanToCornerRoofPieces, LEAN_TO_CORNER_JOINTS_KEY, @@ -29,7 +41,8 @@ import { leanToCornerJointMetadata, resolveLeanToCornerJoints, } from './corner-joint' -import { resolveLeanToLayout } from './layout' +import { isDualSlopeLeanToCanopy, resolveLeanToLayout } from './layout' +import { isLeanToPostOmitted } from './post-omissions' const MANAGED_BY_KEY = 'managedByLeanTo' const MANAGED_ROLE_KEY = 'leanToRole' @@ -39,6 +52,8 @@ const GUTTER_EAVE_Y_KEY = 'leanToGutterEaveY' const GUTTER_ARC_STRAIGHT_ENDS_KEY = 'leanToGutterArcStraightEnds' const POST_INDEX_KEY = 'leanToPostIndex' const POST_SIDE_KEY = 'leanToPostSide' +const DRAINAGE_SIDE_KEY = 'leanToDrainageSide' +const ROOF_PLANE_KEY = 'leanToRoofPlane' const POST_GUTTER_CLEARANCE = 0.02 const POST_GROUND_EMBED = 0.02 const POST_BEAM_EMBED = 0.02 @@ -53,6 +68,8 @@ export function leanToCornerPostIndex(side: LeanToCornerSide): number { type LeanToManagedRole = 'roof' | 'roof-segment' | 'gutter' | 'downspout' | 'post' export type LeanToPostSide = 'high' | 'low' +export type LeanToDrainageSide = 'primary' | 'opposite' +export type LeanToRoofPlane = 'primary' | 'opposite' export type LeanToRoofMaterialPatch = Pick< RoofNodeType, @@ -113,6 +130,16 @@ export function managedLeanToPostSide(column: ColumnNodeType): LeanToPostSide { return metadataRecord(column.metadata)[POST_SIDE_KEY] === 'high' ? 'high' : 'low' } +export function managedLeanToDrainageSide( + node: GutterNodeType | DownspoutNodeType, +): LeanToDrainageSide { + return metadataRecord(node.metadata)[DRAINAGE_SIDE_KEY] === 'opposite' ? 'opposite' : 'primary' +} + +export function managedLeanToRoofPlane(node: RoofSegmentNodeType): LeanToRoofPlane { + return metadataRecord(node.metadata)[ROOF_PLANE_KEY] === 'opposite' ? 'opposite' : 'primary' +} + export type LeanToPostLayoutPatch = Pick< ColumnNodeType, | 'position' @@ -143,14 +170,20 @@ export function leanToPostLayoutPatch( ? ('simple-square' as const) : ('none' as const) const postX = layout.postXs[index] ?? 0 - const postZ = side === 'high' ? 0 : layout.beamZ - gutterSetback + const oppositeCanopySide = side === 'high' && isDualSlopeLeanToCanopy(layout.canopyForm) + const postZ = + side === 'high' + ? oppositeCanopySide + ? layout.oppositeBeamZ + gutterSetback + : 0 + : layout.beamZ - gutterSetback const bent = bendLocalPoint(leanTo, postX, postZ) return { position: [bent.x, baseY, bent.y], rotation: bendRotationYAtLocalX(leanTo, postX), height: Math.max( 0.2, - (side === 'high' + (side === 'high' && !oppositeCanopySide ? layout.highEdgeHeight - leanTo.roofThickness / 2 - leanTo.ledgerHeight + @@ -195,6 +228,39 @@ export function leanToCornerPostLayoutPatch( } } +function canopyJointPostLocalPosition( + leanTo: LeanToExtensionNode, + joint: FreestandingCanopyJoint, + side: LeanToPostSide, + gutterSetback: number, +): [number, number] { + const layout = resolveLeanToLayout(leanTo) + const canopySide = side === 'low' ? 'positive' : 'negative' + const z = side === 'low' ? layout.beamZ - gutterSetback : layout.oppositeBeamZ + gutterSetback + const endpointX = joint.side === 'left' ? -layout.span / 2 : layout.span / 2 + if (joint.kind === 'linear') return [endpointX, z] + const inwardSign = joint.side === 'left' ? 1 : -1 + const trimAtPost = joint.trimZ > 1e-6 ? (joint.trimX * Math.abs(z)) / joint.trimZ : 0 + const inside = joint.innerCanopySide === canopySide + return [endpointX + inwardSign * (inside ? trimAtPost : -trimAtPost), z] +} + +export function leanToCanopyCornerPostLayoutPatch( + leanTo: LeanToExtensionNode, + joint: FreestandingCanopyJoint, + side: LeanToPostSide, + baseY = 0, + gutterSetback = 0, +): LeanToPostLayoutPatch { + const [cornerX, cornerZ] = canopyJointPostLocalPosition(leanTo, joint, side, gutterSetback) + const bent = bendLocalPoint(leanTo, cornerX, cornerZ) + return { + ...leanToPostLayoutPatch(leanTo, 0, baseY, gutterSetback, side), + position: [bent.x, baseY, bent.y], + rotation: bendRotationYAtLocalX(leanTo, cornerX), + } +} + export function resolveLeanToPostGutterSetback( leanTo: LeanToExtensionNode, column?: ColumnNodeType, @@ -222,27 +288,56 @@ 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', ): number { const layout = resolveLeanToLayout(leanTo) const postX = layout.postXs[index] ?? 0 - const postZ = side === 'high' ? 0 : layout.beamZ + const postZ = + side === 'high' && isDualSlopeLeanToCanopy(layout.canopyForm) + ? layout.oppositeBeamZ + : side === 'high' + ? 0 + : layout.beamZ const bent = bendLocalPoint(leanTo, postX, postZ) return resolveLeanToPostBaseYAtLocalPosition(leanTo, wall, nodes, [bent.x, 0, bent.y]) } export function resolveLeanToPostBaseYAtLocalPosition( leanTo: LeanToExtensionNode, - wall: WallNode, + 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 +345,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 +372,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 ) } @@ -281,10 +388,12 @@ export function createManagedLeanToPost( side: LeanToPostSide = 'low', ): ColumnNodeType { const { label: _label, ...preset } = COLUMN_PRESETS.squarePillar + const sideName = + side === 'high' ? (isDualSlopeLeanToCanopy(leanTo.canopyForm) ? 'Opposite ' : 'High ') : '' return ColumnNode.parse({ ...preset, ...leanToPostLayoutPatch(leanTo, index, 0, 0, side), - name: `Lean-to ${side === 'high' ? 'High ' : ''}Post ${index + 1}`, + name: `Lean-to ${sideName}Post ${index + 1}`, parentId: leanTo.id, style: 'plain', edgeSoftness: 0.008, @@ -326,6 +435,33 @@ export function createManagedLeanToCornerPost( }) } +export function createManagedLeanToCanopyCornerPost( + leanTo: LeanToExtensionNode, + joint: FreestandingCanopyJoint, + side: LeanToPostSide, +): ColumnNodeType { + const { label: _label, ...preset } = COLUMN_PRESETS.squarePillar + const sideName = side === 'high' ? ' Opposite' : '' + return ColumnNode.parse({ + ...preset, + ...leanToCanopyCornerPostLayoutPatch(leanTo, joint, side), + name: `Canopy ${joint.side === 'left' ? 'Left' : 'Right'}${sideName} Joint Post`, + parentId: leanTo.id, + style: 'plain', + edgeSoftness: 0.008, + capitalHeight: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + shaftStartScale: 1, + shaftEndScale: 1, + metadata: managedMetadata(leanTo, 'post', { + [POST_INDEX_KEY]: leanToCornerPostIndex(joint.side), + [POST_SIDE_KEY]: side, + }), + }) +} + export function resolveLeanToPostIndexes( leanTo: LeanToExtensionNode, cornerJoints: Partial>, @@ -333,16 +469,36 @@ 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 }) } +export function resolveLeanToCanopyPostIndexes( + leanTo: LeanToExtensionNode, + cornerJoints: Partial>, + canopyJoints: Partial>, + side: LeanToPostSide, +): number[] { + const indexes = resolveLeanToPostIndexes(leanTo, cornerJoints, side) + const layout = resolveLeanToLayout(leanTo) + return indexes.filter((index) => { + const removesEndPost = (joint: FreestandingCanopyJoint | undefined) => + Boolean(joint && (isDualSlopeLeanToCanopy(layout.canopyForm) || joint.kind === 'linear')) + if (index === 0 && removesEndPost(canopyJoints.left)) return false + if (index === layout.postXs.length - 1 && removesEndPost(canopyJoints.right)) return false + return true + }) +} + export type LeanToRoofSegmentLayoutPatch = Pick< RoofSegmentNodeType, | 'position' @@ -362,6 +518,9 @@ export type LeanToRoofSegmentLayoutPatch = Pick< | 'shedSideInfillMaxX' | 'shedFootprintPieces' | 'shedOpenEndSides' + | 'managedByParent' + | 'wallShell' + | 'shedInsetEndPanels' | 'trim' | 'metadata' > @@ -369,6 +528,7 @@ export type LeanToRoofSegmentLayoutPatch = Pick< export function leanToRoofSegmentLayoutPatch( leanTo: LeanToExtensionNode, nodes?: Record, + plane: LeanToRoofPlane = 'primary', ): LeanToRoofSegmentLayoutPatch { const layout = resolveLeanToLayout(leanTo) const wall = @@ -377,10 +537,70 @@ export function leanToRoofSegmentLayoutPatch( : undefined const shingleThickness = leanTo.shingleThickness ?? 0.025 const overhang = 0 + if (isDualSlopeLeanToCanopy(layout.canopyForm)) { + const depth = layout.projection + Math.max(0, leanTo.lowOverhang) + const planeSide = plane === 'primary' ? 'positive' : 'negative' + const planeJointLayout = resolveCanopyRoofPlaneJointLayout(leanTo, nodes, planeSide) + const surfaceProbe = { + roofType: 'shed', + width: planeJointLayout.width, + depth, + wallHeight: 0, + pitch: layout.effectivePitchDegrees, + wallThickness: 0.01, + deckThickness: leanTo.roofThickness, + overhang, + shingleThickness, + } as RoofSegmentNodeType + const positiveSide = plane === 'primary' + const butterfly = layout.canopyForm === 'butterfly' + const rotation = positiveSide === butterfly ? Math.PI : 0 + const topAtReference = getRoofTopSurfaceY(0, -depth / 2, surfaceProbe) + const referenceHeight = butterfly + ? layout.highEdgeHeight + Math.max(0, leanTo.lowOverhang) * Math.tan(layout.pitchRadians) + : layout.highEdgeHeight + return { + position: [ + planeJointLayout.centerX, + referenceHeight - topAtReference, + (positiveSide ? 1 : -1) * (depth / 2), + ], + rotation, + roofType: 'shed', + width: planeJointLayout.width, + depth, + wallHeight: 0, + pitch: layout.effectivePitchDegrees, + wallThickness: 0.01, + deckThickness: leanTo.roofThickness, + shingleThickness, + overhang, + arc: undefined, + shedSideInfillSpan: layout.span, + shedSideInfillMinX: -layout.span / 2 - layout.roofCenterX, + shedSideInfillMaxX: layout.span / 2 - layout.roofCenterX, + shedFootprintPieces: undefined, + shedOpenEndSides: undefined, + managedByParent: true, + wallShell: 'omit', + shedInsetEndPanels: true, + metadata: managedMetadata(leanTo, 'roof-segment', { [ROOF_PLANE_KEY]: plane }), + trim: planeJointLayout.trim, + } + } const depth = layout.roofRun + WALL_CONNECTION_OVERLAP const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) - const leftCornerExtension = cornerJoints.left?.roofExtension ?? 0 - const rightCornerExtension = cornerJoints.right?.roofExtension ?? 0 + const linearCanopyJoints = Object.fromEntries( + Object.entries(resolveFreestandingCanopyJoints(leanTo, nodes)).filter( + ([side, joint]) => joint?.kind === 'linear' && !cornerJoints[side as LeanToCornerSide], + ), + ) as Partial> + const segmentExtension = (joint: LeanToCornerJoint | undefined) => + leanTo.hostKind === 'freestanding' && joint?.kind === 'concave' + ? 0 + : (joint?.roofExtension ?? 0) + const leftCornerExtension = segmentExtension(cornerJoints.left) + const rightCornerExtension = segmentExtension(cornerJoints.right) const width = Math.max(0.05, layout.roofWidth + leftCornerExtension + rightCornerExtension) const roofCenterX = layout.roofCenterX + (rightCornerExtension - leftCornerExtension) / 2 const roofCenterZ = @@ -410,7 +630,12 @@ export function leanToRoofSegmentLayoutPatch( ).map((polygon) => polygon.map(([x = 0, z = 0]) => [x - roofCenterX, z - roofCenterZ] as [number, number]), ) - const jointSides = Object.values(cornerJoints).flatMap((joint) => (joint ? [joint.side] : [])) + const jointSides = [...Object.values(cornerJoints), ...Object.values(linearCanopyJoints)].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,12 +672,15 @@ 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, - metadata: managedMetadata(leanTo, 'roof-segment'), + managedByParent: true, + wallShell: 'omit', + shedInsetEndPanels: true, + metadata: managedMetadata(leanTo, 'roof-segment', { [ROOF_PLANE_KEY]: plane }), trim: { - left: 0, - right: 0, + left: linearCanopyJoints.left ? leanTo.leftOverhang : 0, + right: linearCanopyJoints.right ? leanTo.rightOverhang : 0, front: 0, back: leanTo.highOverhang > 0 ? 0 : WALL_CONNECTION_TRIM, frontLeft: 0, @@ -476,6 +704,7 @@ export function leanToGutterLayoutPatch( leanTo: LeanToExtensionNode, gutter?: GutterNodeType, nodes?: Record, + drainageSide: LeanToDrainageSide = 'primary', ): Pick< GutterNodeType, | 'position' @@ -486,17 +715,42 @@ export function leanToGutterLayoutPatch( | 'visible' | 'profile' | 'size' + | 'endCapLeft' + | 'endCapRight' | 'outlets' | 'metadata' > { - const snap = resolveEaveSnap(segment, 0, segment.depth / 2) + const dualSlope = isDualSlopeLeanToCanopy(leanTo.canopyForm) + const snap = resolveEaveSnap( + segment, + 0, + dualSlope || drainageSide === 'primary' ? segment.depth / 2 : -segment.depth / 2, + ) const existingOutlet = gutter?.outlets[0] const outletId = existingOutlet?.id ?? generateId('outlet') const wall = leanTo.parentId && nodes?.[leanTo.parentId]?.type === 'wall' ? (nodes[leanTo.parentId] as WallNode) : undefined - const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) + const planeSide = drainageSide === 'primary' ? 'positive' : 'negative' + const canopyGutter = + leanTo.hostKind === 'freestanding' + ? resolveCanopyGutterJointLayout(leanTo, nodes, planeSide) + : undefined + const segmentXSign = Math.cos(segment.rotation) < 0 ? -1 : 1 + const localSideForPhysicalSide = (side: LeanToCornerSide): LeanToCornerSide => + segmentXSign < 0 ? (side === 'left' ? 'right' : 'left') : side + const relevantCanopyJoints = Object.fromEntries( + Object.entries(canopyGutter?.joints ?? {}).flatMap(([rawSide, joint]) => { + if (!joint) return [] + return [[localSideForPhysicalSide(rawSide as LeanToCornerSide), joint]] + }), + ) as Partial> + const cornerJoints = canopyGutter + ? relevantCanopyJoints + : drainageSide === 'primary' + ? resolveLeanToCornerJoints(leanTo, wall, nodes) + : {} const ownWorldEaveY = (wall && nodes ? getWallBaseElevationForNodes(wall, nodes) : 0) + leanTo.position[1] + @@ -525,12 +779,27 @@ export function leanToGutterLayoutPatch( } } const sharedLocalEaveY = sharedWorldEaveY - ownWorldEaveY + snap.eaveY - const gutterMitreForJoint = (joint: LeanToCornerJoint | undefined): number => { + const gutterMitreForJoint = ( + joint: LeanToCornerJoint | FreestandingCanopyJoint | undefined, + ): number => { if (!(leanTo.gutterEnabled && joint && nodes)) return 0 const neighbor = nodes[joint.neighborId] return neighbor?.type === 'lean-to-extension' && neighbor.gutterEnabled ? joint.gutterMitre : 0 } - const length = Math.max(0.05, segment.width + 2 * segment.overhang) + const gutterOpenAtJoint = ( + joint: LeanToCornerJoint | FreestandingCanopyJoint | undefined, + ): boolean => { + if (!(leanTo.gutterEnabled && joint && nodes)) return false + const neighbor = nodes[joint.neighborId] + return neighbor?.type === 'lean-to-extension' && neighbor.gutterEnabled + } + const canopyLocalXs = canopyGutter + ? [canopyGutter.minX, canopyGutter.maxX].map((x) => (x - segment.position[0]) * segmentXSign) + : undefined + const gutterCenterX = canopyLocalXs ? ((canopyLocalXs[0] ?? 0) + (canopyLocalXs[1] ?? 0)) / 2 : 0 + const length = canopyLocalXs + ? Math.max(0.05, Math.abs((canopyLocalXs[1] ?? 0) - (canopyLocalXs[0] ?? 0))) + : Math.max(0.05, segment.width + 2 * segment.overhang) const jointAwareDownspoutPosition = cornerJoints.left && leanTo.downspoutPosition < -0.75 ? cornerJoints.right @@ -575,7 +844,7 @@ export function leanToGutterLayoutPatch( generatedBy: 'default-downspout' as const, } return { - position: [snap.eaveX, snap.eaveY, snap.eaveZ], + position: [snap.eaveX + gutterCenterX, snap.eaveY, snap.eaveZ], rotation: snap.rotation, length, arc: gutterArc, @@ -583,10 +852,13 @@ 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), ...managedMetadata(leanTo, 'gutter', { + [DRAINAGE_SIDE_KEY]: drainageSide, [GUTTER_MITRES_KEY]: { left: gutterMitreForJoint(cornerJoints.left), right: gutterMitreForJoint(cornerJoints.right), @@ -632,8 +904,58 @@ export function leanToRoofMaterialPatch(hostRoof: RoofNodeType): LeanToRoofMater export type LeanToRoofAssembly = { roof: RoofNodeType segment: RoofSegmentNodeType + oppositeSegment?: RoofSegmentNodeType gutter: GutterNodeType downspout: DownspoutNodeType + oppositeGutter?: GutterNodeType + oppositeDownspout?: DownspoutNodeType +} + +export function createManagedLeanToRoofSegment( + leanTo: LeanToExtensionNode, + roofId: RoofNodeType['id'], + plane: LeanToRoofPlane = 'primary', + nodes?: Record, +): RoofSegmentNodeType { + const canopyForm = resolveLeanToLayout(leanTo).canopyForm + const name = + canopyForm === 'gable' + ? 'Canopy Gable Roof' + : canopyForm === 'butterfly' + ? plane === 'primary' + ? 'Canopy Butterfly Right Roof' + : 'Canopy Butterfly Left Roof' + : 'Lean-to Shed Roof' + return RoofSegmentNode.parse({ + ...leanToRoofSegmentLayoutPatch(leanTo, nodes, plane), + name, + parentId: roofId, + }) +} + +export function createManagedLeanToDrainagePair( + segment: RoofSegmentNodeType, + leanTo: LeanToExtensionNode, + drainageSide: LeanToDrainageSide, + nodes?: Record, +): { gutter: GutterNodeType; downspout: DownspoutNodeType } { + const gutter = GutterNode.parse({ + ...leanToGutterLayoutPatch(segment, leanTo, undefined, nodes, drainageSide), + name: drainageSide === 'opposite' ? 'Canopy Opposite Gutter' : 'Lean-to Gutter', + parentId: segment.id, + }) + const downspout = DownspoutNode.parse({ + ...leanToDownspoutLayoutPatch(segment, gutter, leanTo), + name: drainageSide === 'opposite' ? 'Canopy Opposite Downspout' : 'Lean-to Downspout', + parentId: segment.id, + lengthMode: 'to-ground', + strapStyle: 'none', + terminal: 'straight', + metadata: managedMetadata(leanTo, 'downspout', { + [DRAINAGE_SIDE_KEY]: drainageSide, + }), + }) + return { gutter, downspout } } export function createManagedLeanToRoofAssembly( @@ -641,6 +963,7 @@ export function createManagedLeanToRoofAssembly( hostRoof?: RoofNodeType, nodes?: Record, ): LeanToRoofAssembly { + const canopyForm = resolveLeanToLayout(leanTo).canopyForm const roof = RoofNode.parse({ ...(hostRoof && leanTo.matchHostRoofMaterial !== false ? leanToRoofMaterialPatch(hostRoof) @@ -651,31 +974,32 @@ export function createManagedLeanToRoofAssembly( rotation: 0, metadata: managedMetadata(leanTo, 'roof'), }) - const segment = RoofSegmentNode.parse({ - ...leanToRoofSegmentLayoutPatch(leanTo, nodes), - name: 'Lean-to Shed Roof', - parentId: roof.id, - }) - const gutter = GutterNode.parse({ - ...leanToGutterLayoutPatch(segment, leanTo, undefined, nodes), - name: 'Lean-to Gutter', - parentId: segment.id, - }) - const downspout = DownspoutNode.parse({ - ...leanToDownspoutLayoutPatch(segment, gutter, leanTo), - name: 'Lean-to Downspout', - parentId: segment.id, - lengthMode: 'to-ground', - strapStyle: 'none', - terminal: 'straight', - metadata: managedMetadata(leanTo, 'downspout'), - }) + const segment = createManagedLeanToRoofSegment(leanTo, roof.id, 'primary', nodes) + const oppositeSegment = isDualSlopeLeanToCanopy(canopyForm) + ? createManagedLeanToRoofSegment(leanTo, roof.id, 'opposite', nodes) + : undefined + const { gutter, downspout } = createManagedLeanToDrainagePair(segment, leanTo, 'primary', nodes) + const opposite = + canopyForm === 'gable' && oppositeSegment + ? createManagedLeanToDrainagePair(oppositeSegment, leanTo, 'opposite', nodes) + : undefined return { - roof: { ...roof, children: [segment.id] }, - segment: { ...segment, children: [gutter.id, downspout.id] }, + roof: { ...roof, children: [segment.id, ...(oppositeSegment ? [oppositeSegment.id] : [])] }, + segment: { + ...segment, + children: [gutter.id, downspout.id], + }, + oppositeSegment: oppositeSegment + ? { + ...oppositeSegment, + children: opposite ? [opposite.gutter.id, opposite.downspout.id] : [], + } + : undefined, gutter, downspout, + oppositeGutter: opposite?.gutter, + oppositeDownspout: opposite?.downspout, } } @@ -687,6 +1011,7 @@ export function createLeanToAssembly( extension: LeanToExtensionNode roof: RoofNodeType segment: RoofSegmentNodeType + oppositeSegment?: RoofSegmentNodeType gutter: GutterNodeType downspout: DownspoutNodeType posts: ColumnNodeType[] @@ -698,24 +1023,42 @@ export function createLeanToAssembly( ? (nodes[leanTo.parentId] as WallNode) : undefined const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) - const posts = resolveLeanToPostIndexes(leanTo, cornerJoints, 'low').map((index) => - createManagedLeanToPost(leanTo, index, 'low'), + const canopyJoints = resolveFreestandingCanopyJoints(leanTo, nodes) + const posts = resolveLeanToCanopyPostIndexes(leanTo, cornerJoints, canopyJoints, 'low').map( + (index) => 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( - ...resolveLeanToPostIndexes(leanTo, cornerJoints, 'high').map((index) => + ...resolveLeanToCanopyPostIndexes(leanTo, cornerJoints, canopyJoints, 'high').map((index) => createManagedLeanToPost(leanTo, index, 'high'), ), ) } + for (const joint of Object.values(canopyJoints)) { + if (!joint?.sharedPostOwner || cornerJoints[joint.side]) continue + const sides: LeanToPostSide[] = + leanTo.highSideMode === 'independent-high-beam' ? ['low', 'high'] : ['low'] + for (const side of sides) { + if (isLeanToPostOmitted(leanTo, side, leanToCornerPostIndex(joint.side))) continue + posts.push(createManagedLeanToCanopyCornerPost(leanTo, joint, side)) + } + } const children: AnyNode[] = [ roofAssembly.roof, roofAssembly.segment, + ...(roofAssembly.oppositeSegment ? [roofAssembly.oppositeSegment] : []), roofAssembly.gutter, roofAssembly.downspout, + ...(roofAssembly.oppositeGutter ? [roofAssembly.oppositeGutter] : []), + ...(roofAssembly.oppositeDownspout ? [roofAssembly.oppositeDownspout] : []), ...posts, ] return { @@ -724,6 +1067,7 @@ export function createLeanToAssembly( metadata: { ...metadataRecord(leanTo.metadata), [LEAN_TO_CORNER_JOINTS_KEY]: leanToCornerJointMetadata(cornerJoints), + [FREESTANDING_CANOPY_JOINTS_KEY]: canopyCornerJointMetadata(canopyJoints), }, children: [roofAssembly.roof.id, ...posts.map((post) => post.id)], }, diff --git a/packages/nodes/src/lean-to-extension/canopy-joint.test.ts b/packages/nodes/src/lean-to-extension/canopy-joint.test.ts new file mode 100644 index 0000000000..c78a69c5b5 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/canopy-joint.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LeanToExtensionNode, LevelNode } from '@pascal-app/core' +import { + resolveCanopyGutterJointLayout, + resolveCanopyRoofPlaneJointLayout, + resolveFreestandingCanopyJoints, +} from './canopy-joint' +import { resolveLeanToFreestandingRunPlacement } from './placement' + +function joinedRuns(canopyForm: 'gable' | 'butterfly', end: readonly [number, number] = [4, 4]) { + const level = LevelNode.parse({ id: `level_${canopyForm}`, level: 0 }) + const first = resolveLeanToFreestandingRunPlacement(level.id, [0, 0], [4, 0], false, canopyForm)! + const second = resolveLeanToFreestandingRunPlacement(level.id, [4, 0], end, false, canopyForm)! + const nodes = Object.fromEntries([level, first, second].map((node) => [node.id, node])) as Record< + string, + AnyNode + > + return { first, second, nodes } +} + +function jointCutPoint( + node: LeanToExtensionNode, + side: 'left' | 'right', + localZ: number, + localXOffset: number, +): [number, number] { + const endpointX = side === 'left' ? -node.span / 2 : node.span / 2 + const cos = Math.cos(node.rotation[1]) + const sin = Math.sin(node.rotation[1]) + const localX = endpointX + localXOffset + return [ + node.position[0] + localX * cos + localZ * sin, + node.position[2] - localX * sin + localZ * cos, + ] +} + +describe('freestanding canopy corner joints', () => { + test.each([ + 'gable', + 'butterfly', + ] as const)('resolves reciprocal 90-degree %s cuts on the inside canopy halves', (canopyForm) => { + const { first, second, nodes } = joinedRuns(canopyForm) + const firstJoint = resolveFreestandingCanopyJoints(first, nodes).right + const secondJoint = resolveFreestandingCanopyJoints(second, nodes).left + + expect(firstJoint).toMatchObject({ neighborId: second.id, innerCanopySide: 'positive' }) + expect(secondJoint).toMatchObject({ neighborId: first.id, innerCanopySide: 'positive' }) + expect(firstJoint?.trimX).toBeCloseTo(first.projection + first.lowOverhang, 8) + expect(firstJoint?.trimZ).toBeCloseTo(first.projection + first.lowOverhang, 8) + expect(firstJoint?.gutterMitre).toBeCloseTo(-Math.PI / 4, 8) + expect(firstJoint?.sharedPostOwner).not.toBe(secondJoint?.sharedPostOwner) + }) + + test('uses the angle bisector for non-square turns', () => { + const { first, nodes } = joinedRuns('gable', [6, 2 * Math.sqrt(3)]) + const joint = resolveFreestandingCanopyJoints(first, nodes).right + + expect(joint?.interiorAngle).toBeCloseTo((2 * Math.PI) / 3, 8) + expect(joint?.trimX).toBeCloseTo(joint!.trimZ / Math.tan(Math.PI / 3), 8) + }) + + test('produces reciprocal bisector cuts across shallow, square, and reflex turns', () => { + for (const canopyForm of ['mono', 'gable', 'butterfly'] as const) { + for (const turnDirection of [-1, 1] as const) { + for (const turnDegrees of [5, 15, 30, 60, 90, 120, 150, 165, 175]) { + const level = LevelNode.parse({ + id: `level_cut_${canopyForm}_${turnDirection}_${turnDegrees}`, + level: 0, + }) + const radians = (turnDirection * turnDegrees * Math.PI) / 180 + const corner: [number, number] = [100, 0] + const end: [number, number] = [ + corner[0] + 100 * Math.cos(radians), + corner[1] + 100 * Math.sin(radians), + ] + const first = resolveLeanToFreestandingRunPlacement( + level.id, + [0, 0], + corner, + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + corner, + end, + false, + canopyForm, + )! + const nodes = Object.fromEntries( + [level, first, second].map((node) => [node.id, node]), + ) as Record + const firstJoint = resolveFreestandingCanopyJoints(first, nodes).right! + const secondJoint = resolveFreestandingCanopyJoints(second, nodes).left! + const firstZ = + firstJoint.innerCanopySide === 'positive' ? firstJoint.trimZ : -firstJoint.trimZ + const secondZ = + secondJoint.innerCanopySide === 'positive' ? secondJoint.trimZ : -secondJoint.trimZ + const firstPoint = jointCutPoint(first, 'right', firstZ, -firstJoint.trimX) + const secondPoint = jointCutPoint(second, 'left', secondZ, secondJoint.trimX) + + expect( + Math.hypot(firstPoint[0] - secondPoint[0], firstPoint[1] - secondPoint[1]), + ).toBeLessThan(1e-8) + expect(firstJoint.sharedPostOwner).not.toBe(secondJoint.sharedPostOwner) + } + } + } + }) + + test('does not create a cosmetic miter between incompatible roof profiles', () => { + const { first, second, nodes } = joinedRuns('gable') + nodes[second.id] = LeanToExtensionNode.parse({ ...second, pitch: second.pitch + 2 }) + + expect(resolveFreestandingCanopyJoints(first, nodes)).toEqual({}) + }) + + test('does not join different canopy forms', () => { + const { first, second, nodes } = joinedRuns('gable') + nodes[second.id] = LeanToExtensionNode.parse({ ...second, canopyForm: 'butterfly' }) + + expect(resolveFreestandingCanopyJoints(first, nodes)).toEqual({}) + }) + + test.each([ + 'gable', + 'butterfly', + ] as const)('retreats the inner %s plane and extends the outer plane to the hip', (canopyForm) => { + const { first, nodes } = joinedRuns(canopyForm) + const run = first.projection + first.lowOverhang + const inner = resolveCanopyRoofPlaneJointLayout(first, nodes, 'positive') + const outer = resolveCanopyRoofPlaneJointLayout(first, nodes, 'negative') + + expect(inner.width).toBeCloseTo(first.span + first.leftOverhang + first.rightOverhang) + expect(outer.width).toBeCloseTo(inner.width + run - first.rightOverhang) + expect(outer.centerX).toBeCloseTo((run - first.rightOverhang) / 2) + if (canopyForm === 'gable') { + expect(inner.trim.frontRightX).toBeCloseTo(run) + expect(outer.trim.backLeftX).toBeCloseTo(run) + } else { + expect(inner.trim.backLeftX).toBeCloseTo(run) + expect(outer.trim.frontRightX).toBeCloseTo(run) + } + }) + + test('extends the outside gable eave while retreating the inside eave', () => { + const { first, nodes } = joinedRuns('gable') + const run = first.projection + first.lowOverhang + const inner = resolveCanopyGutterJointLayout(first, nodes, 'positive') + const outer = resolveCanopyGutterJointLayout(first, nodes, 'negative') + + expect(inner.maxX).toBeCloseTo(first.span / 2 - run) + expect(outer.maxX).toBeCloseTo(first.span / 2 + run) + expect(inner.joints.right?.gutterMitre).toBeCloseTo(-Math.PI / 4) + expect(outer.joints.right?.gutterMitre).toBeCloseTo(Math.PI / 4) + }) + + test('terminates a joined butterfly valley gutter at the structural corner', () => { + const { first, nodes } = joinedRuns('butterfly') + const gutter = resolveCanopyGutterJointLayout(first, nodes, 'positive') + + expect(gutter.maxX).toBeCloseTo(first.span / 2) + expect(gutter.joints.right?.gutterMitre).toBeCloseTo(-Math.PI / 4) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/canopy-joint.ts b/packages/nodes/src/lean-to-extension/canopy-joint.ts new file mode 100644 index 0000000000..8ba4f240f7 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/canopy-joint.ts @@ -0,0 +1,326 @@ +import { + type AnyNode, + type LeanToExtensionNode, + normalizeRoofSegmentTrim, + type RoofSegmentTrim, +} from '@pascal-app/core' +import type { LeanToCornerSide } from './corner-joint' +import { resolveLeanToLayout } from './layout' + +const ENDPOINT_TOLERANCE = 0.05 +const PROFILE_TOLERANCE = 1e-4 +const DIRECTION_TOLERANCE = 1e-6 + +export const FREESTANDING_CANOPY_JOINTS_KEY = 'leanToFreestandingCanopyJoints' + +type PlanVector = readonly [number, number] + +export type CanopySide = 'positive' | 'negative' + +export type FreestandingCanopyJoint = { + side: LeanToCornerSide + kind: 'corner' | 'linear' + neighborId: string + neighborSide: LeanToCornerSide + innerCanopySide: CanopySide + interiorAngle: number + trimX: number + trimZ: number + gutterMitre: number + sharedPostOwner: boolean +} + +export type CanopyRoofPlaneJointLayout = { + centerX: number + trim: RoofSegmentTrim + width: number +} + +export type CanopyGutterJointLayout = { + joints: Partial> + maxX: number + minX: number +} + +export type FreestandingCanopyJointMetadata = Partial< + Record< + LeanToCornerSide, + Pick< + FreestandingCanopyJoint, + 'kind' | 'innerCanopySide' | 'trimX' | 'trimZ' | 'gutterMitre' | 'sharedPostOwner' + > + > +> + +export function canopyCornerJointMetadata( + joints: Partial>, +): FreestandingCanopyJointMetadata { + const metadata: FreestandingCanopyJointMetadata = {} + for (const [side, joint] of Object.entries(joints)) { + if (!joint) continue + metadata[side as LeanToCornerSide] = { + kind: joint.kind, + innerCanopySide: joint.innerCanopySide, + trimX: joint.trimX, + trimZ: joint.trimZ, + gutterMitre: joint.gutterMitre, + sharedPostOwner: joint.sharedPostOwner, + } + } + return metadata +} + +export function readFreestandingCanopyJointMetadata( + leanTo: LeanToExtensionNode, +): FreestandingCanopyJointMetadata { + const metadata = leanTo.metadata + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {} + const value = (metadata as Record)[FREESTANDING_CANOPY_JOINTS_KEY] + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as FreestandingCanopyJointMetadata) + : {} +} + +function dot(a: PlanVector, b: PlanVector): number { + return a[0] * b[0] + a[1] * b[1] +} + +function runAxis(node: LeanToExtensionNode): PlanVector { + return [Math.cos(node.rotation[1]), -Math.sin(node.rotation[1])] +} + +function positiveCanopyAxis(node: LeanToExtensionNode): PlanVector { + return [Math.sin(node.rotation[1]), Math.cos(node.rotation[1])] +} + +function endpoint(node: LeanToExtensionNode, side: LeanToCornerSide): PlanVector { + const axis = runAxis(node) + const sign = side === 'left' ? -1 : 1 + return [ + node.position[0] + sign * axis[0] * (node.span / 2), + node.position[2] + sign * axis[1] * (node.span / 2), + ] +} + +function inwardDirection(node: LeanToExtensionNode, side: LeanToCornerSide): PlanVector { + const axis = runAxis(node) + const sign = side === 'left' ? 1 : -1 + return [sign * axis[0], sign * axis[1]] +} + +function distance(a: PlanVector, b: PlanVector): number { + return Math.hypot(a[0] - b[0], a[1] - b[1]) +} + +function sameRoofProfile(a: LeanToExtensionNode, b: LeanToExtensionNode): boolean { + return ( + a.canopyForm === b.canopyForm && + Math.abs(a.projection - b.projection) <= PROFILE_TOLERANCE && + Math.abs(a.highOverhang - b.highOverhang) <= PROFILE_TOLERANCE && + Math.abs(a.lowOverhang - b.lowOverhang) <= PROFILE_TOLERANCE && + Math.abs(a.highEdgeHeight - b.highEdgeHeight) <= PROFILE_TOLERANCE && + Math.abs(a.pitch - b.pitch) <= PROFILE_TOLERANCE && + Math.abs(a.roofThickness - b.roofThickness) <= PROFILE_TOLERANCE + ) +} + +function matchingEndpoint( + candidate: LeanToExtensionNode, + point: PlanVector, +): { distance: number; side: LeanToCornerSide } | null { + const matches = (['left', 'right'] as const) + .map((side) => ({ distance: distance(endpoint(candidate, side), point), side })) + .filter((match) => match.distance <= ENDPOINT_TOLERANCE) + .sort((a, b) => a.distance - b.distance || a.side.localeCompare(b.side)) + return matches[0] ?? null +} + +function jointAt( + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + candidate: LeanToExtensionNode, + neighborSide: LeanToCornerSide, +): FreestandingCanopyJoint | null { + const ownInward = inwardDirection(leanTo, side) + const neighborInward = inwardDirection(candidate, neighborSide) + const directionDot = Math.max(-1, Math.min(1, dot(ownInward, neighborInward))) + const interiorAngle = Math.acos(directionDot) + const layout = resolveLeanToLayout(leanTo) + const trimZ = layout.projection + Math.max(0, leanTo.lowOverhang) + if (interiorAngle >= Math.PI - DIRECTION_TOLERANCE) { + return { + side, + kind: 'linear', + neighborId: candidate.id, + neighborSide, + innerCanopySide: 'positive', + interiorAngle: Math.PI, + trimX: 0, + trimZ, + gutterMitre: 0, + sharedPostOwner: String(leanTo.id) < String(candidate.id), + } + } + if (interiorAngle <= DIRECTION_TOLERANCE) return null + + const bisectorLength = Math.hypot( + ownInward[0] + neighborInward[0], + ownInward[1] + neighborInward[1], + ) + if (bisectorLength <= DIRECTION_TOLERANCE) return null + const bisector: PlanVector = [ + (ownInward[0] + neighborInward[0]) / bisectorLength, + (ownInward[1] + neighborInward[1]) / bisectorLength, + ] + const lateral = dot(bisector, positiveCanopyAxis(leanTo)) + if (Math.abs(lateral) <= DIRECTION_TOLERANCE) return null + + const trimX = Math.abs((dot(bisector, runAxis(leanTo)) / lateral) * trimZ) + if (!Number.isFinite(trimX)) return null + + return { + side, + kind: 'corner', + neighborId: candidate.id, + neighborSide, + innerCanopySide: lateral > 0 ? 'positive' : 'negative', + interiorAngle, + trimX, + trimZ, + gutterMitre: -(Math.PI - interiorAngle) / 2, + sharedPostOwner: String(leanTo.id) < String(candidate.id), + } +} + +export function resolveFreestandingCanopyJoints( + leanTo: LeanToExtensionNode, + nodes: Record | undefined, +): Partial> { + if (!nodes || leanTo.hostKind !== 'freestanding' || !leanTo.autoMiterCorners) return {} + + const candidates = Object.values(nodes).filter( + (candidate): candidate is LeanToExtensionNode => + candidate.type === 'lean-to-extension' && + candidate.id !== leanTo.id && + candidate.parentId === leanTo.parentId && + candidate.hostKind === 'freestanding' && + candidate.autoMiterCorners && + sameRoofProfile(leanTo, candidate), + ) + const joints: Partial> = {} + + for (const side of ['left', 'right'] as const) { + const ownEndpoint = endpoint(leanTo, side) + const matches = candidates + .flatMap((candidate) => { + const match = matchingEndpoint(candidate, ownEndpoint) + return match ? [{ candidate, ...match }] : [] + }) + .sort( + (a, b) => + a.distance - b.distance || + String(a.candidate.id).localeCompare(String(b.candidate.id)) || + a.side.localeCompare(b.side), + ) + for (const match of matches) { + const joint = jointAt(leanTo, side, match.candidate, match.side) + if (!joint) continue + joints[side] = joint + break + } + } + + return joints +} + +export function resolveCanopyRoofPlaneJointLayout( + leanTo: LeanToExtensionNode, + nodes: Record | undefined, + planeSide: CanopySide, +): CanopyRoofPlaneJointLayout { + const layout = resolveLeanToLayout(leanTo) + const depth = layout.projection + Math.max(0, leanTo.lowOverhang) + const joints = resolveFreestandingCanopyJoints(leanTo, nodes) + const extensions = { left: 0, right: 0 } + const baseTrims = { left: 0, right: 0 } + const diagonals: Array<{ + edge: 'front' | 'back' + segmentSide: LeanToCornerSide + trimX: number + trimZ: number + }> = [] + const flipsX = + (leanTo.canopyForm === 'gable' && planeSide === 'negative') || + (leanTo.canopyForm === 'butterfly' && planeSide === 'positive') + const outerEdge = leanTo.canopyForm === 'gable' ? 'front' : 'back' + + for (const [side, joint] of Object.entries(joints) as [ + LeanToCornerSide, + NonNullable<(typeof joints)[LeanToCornerSide]>, + ][]) { + const overhang = side === 'left' ? leanTo.leftOverhang : leanTo.rightOverhang + const segmentSide = flipsX ? (side === 'left' ? 'right' : 'left') : side + if (joint.kind === 'linear') { + baseTrims[segmentSide] = overhang + continue + } + const inside = joint.innerCanopySide === planeSide + if (inside) { + baseTrims[segmentSide] = overhang + } else { + const extension = joint.trimX - overhang + if (extension >= 0) extensions[side] = extension + else baseTrims[segmentSide] = -extension + } + diagonals.push({ + edge: inside ? outerEdge : outerEdge === 'front' ? 'back' : 'front', + segmentSide, + trimX: joint.trimX, + trimZ: joint.trimZ, + }) + } + + const width = layout.roofWidth + extensions.left + extensions.right + const centerX = layout.roofCenterX + (extensions.right - extensions.left) / 2 + const trim = normalizeRoofSegmentTrim({ width, depth }) + trim.left = baseTrims.left + trim.right = baseTrims.right + for (const diagonal of diagonals) { + const corner = `${diagonal.edge}${diagonal.segmentSide === 'left' ? 'Left' : 'Right'}` as const + trim[`${corner}X`] = diagonal.trimX + trim[`${corner}Z`] = diagonal.trimZ + } + return { centerX, trim, width } +} + +export function resolveCanopyGutterJointLayout( + leanTo: LeanToExtensionNode, + nodes: Record | undefined, + planeSide: CanopySide, +): CanopyGutterJointLayout { + const joints = resolveFreestandingCanopyJoints(leanTo, nodes) + const resolvedJoints: CanopyGutterJointLayout['joints'] = {} + let minX = -leanTo.span / 2 - leanTo.leftOverhang + let maxX = leanTo.span / 2 + leanTo.rightOverhang + + for (const [side, joint] of Object.entries(joints) as [ + LeanToCornerSide, + NonNullable<(typeof joints)[LeanToCornerSide]>, + ][]) { + const butterfly = leanTo.canopyForm === 'butterfly' + const inside = joint.innerCanopySide === planeSide + const endpointX = side === 'left' ? -leanTo.span / 2 : leanTo.span / 2 + const direction = side === 'left' ? -1 : 1 + const boundaryX = butterfly + ? endpointX + : endpointX + direction * (inside ? -joint.trimX : joint.trimX) + if (side === 'left') minX = boundaryX + else maxX = boundaryX + resolvedJoints[side] = { + ...joint, + gutterMitre: inside || butterfly ? joint.gutterMitre : -joint.gutterMitre, + } + } + + return { joints: resolvedJoints, maxX, minX } +} 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..ab42bc0c58 100644 --- a/packages/nodes/src/lean-to-extension/corner-joint.ts +++ b/packages/nodes/src/lean-to-extension/corner-joint.ts @@ -1,10 +1,13 @@ -import type { AnyNode, LeanToExtensionNode, WallNode } from '@pascal-app/core' +import { type AnyNode, type LeanToExtensionNode, unionPolygons, WallNode } from '@pascal-app/core' import { bendLocalPoint, isCurvedLeanTo, leanToArcFrameAtLocalX } from './arc' +import { resolveFreestandingCanopyJoints } from './canopy-joint' 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 LeanToFramingRetainedSide = 'front' | 'back' export type LeanToCornerJoint = { side: LeanToCornerSide @@ -13,7 +16,11 @@ export type LeanToCornerJoint = { neighborSide: LeanToCornerSide roofExtension: number roofPiece: LeanToPlanPoint[] + roofPieces?: LeanToPlanPoint[][] + roofAdditionPieces?: LeanToPlanPoint[][] + mergeRoofPieces?: boolean seam: [LeanToPlanPoint, LeanToPlanPoint] | null + framingRetainedSide?: LeanToFramingRetainedSide beamExtension: number gutterMitre: number sharedPostOwner: boolean @@ -25,13 +32,26 @@ export const LEAN_TO_CORNER_JOINTS_KEY = 'leanToCornerJoints' const WALL_CONNECTION_OVERLAP = 0.02 const WALL_CONNECTION_TRIM = 0.002 const PLAN_TOLERANCE = 1e-6 -const MIN_CORNER_ANGLE = Math.PI / 6 -const MAX_CORNER_ANGLE = (5 * Math.PI) / 6 +const MIN_NON_COLLINEAR_ANGLE = 1e-4 +const LINEAR_DIRECTION_TOLERANCE = 1e-3 +const LINEAR_JOIN_PLAN_TOLERANCE = 0.03 +const FREESTANDING_JOINT_WALL_THICKNESS = 0.1 +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_NON_COLLINEAR_ANGLE && angle < Math.PI - MIN_NON_COLLINEAR_ANGLE +} + function wallFrame(wall: WallNode) { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] @@ -44,6 +64,52 @@ function wallFrame(wall: WallNode) { } } +type LeanToJointFrame = { + kind: 'wall' | 'freestanding' + leanTo: LeanToExtensionNode + wall: WallNode +} + +function resolveLeanToJointFrame( + leanTo: LeanToExtensionNode, + wall: WallNode | undefined, +): LeanToJointFrame | null { + if (wall) return { kind: 'wall', leanTo, wall } + if (!(leanTo.hostKind === 'freestanding' && leanTo.canopyForm === 'mono' && leanTo.parentId)) { + return null + } + const halfSpan = leanTo.span / 2 + const cos = Math.cos(leanTo.rotation[1]) + const sin = Math.sin(leanTo.rotation[1]) + const surfaceOffset = FREESTANDING_JOINT_WALL_THICKNESS / 2 + const syntheticWall = WallNode.parse({ + name: 'Freestanding canopy run frame', + parentId: leanTo.parentId, + start: [ + leanTo.position[0] - halfSpan * cos - surfaceOffset * sin, + leanTo.position[2] + halfSpan * sin - surfaceOffset * cos, + ], + end: [ + leanTo.position[0] + halfSpan * cos - surfaceOffset * sin, + leanTo.position[2] - halfSpan * sin - surfaceOffset * cos, + ], + height: leanTo.highEdgeHeight, + thickness: FREESTANDING_JOINT_WALL_THICKNESS, + }) + return { + kind: 'freestanding', + wall: syntheticWall, + leanTo: { + ...leanTo, + parentId: syntheticWall.id, + position: [halfSpan, leanTo.position[1], surfaceOffset], + rotation: [0, 0, 0], + spanArcCenterZ: undefined, + spanArcRadius: undefined, + }, + } +} + function leanToOutwardDirection( wall: WallNode, leanTo: LeanToExtensionNode, @@ -106,6 +172,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 +186,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 +305,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( @@ -298,7 +471,10 @@ function leanToTopHeightAtWorld( return leanTo.position[1] + layout.highEdgeHeight - local[1] * Math.tan(layout.pitchRadians) } -function roofPlanEdges(leanTo: LeanToExtensionNode): { back: number; front: number } { +function roofPlanEdges(leanTo: LeanToExtensionNode): { + back: number + front: number +} { const layout = resolveLeanToLayout(leanTo) const depth = layout.roofRun + WALL_CONNECTION_OVERLAP const centerZ = @@ -418,6 +594,145 @@ 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 resolveFramingRetainedSide( + seam: [LeanToPlanPoint, LeanToPlanPoint] | null, + pieces: readonly LeanToPlanPoint[][], +): LeanToFramingRetainedSide | undefined { + if (!seam || pieces.length === 0) return undefined + const midpoint: LeanToPlanPoint = [(seam[0][0] + seam[1][0]) / 2, (seam[0][1] + seam[1][1]) / 2] + const probeDistance = 0.01 + const contains = (point: LeanToPlanPoint) => + pieces.some((polygon) => pointInPlanPolygon(point, polygon)) + const front = contains([midpoint[0], midpoint[1] + probeDistance]) + const back = contains([midpoint[0], midpoint[1] - probeDistance]) + if (front === back) return undefined + return front ? 'front' : 'back' +} + +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 planSegmentsShareLength( + leftStart: LeanToPlanPoint, + leftEnd: LeanToPlanPoint, + rightStart: LeanToPlanPoint, + rightEnd: LeanToPlanPoint, +): boolean { + const leftX = leftEnd[0] - leftStart[0] + const leftZ = leftEnd[1] - leftStart[1] + const leftLength = Math.hypot(leftX, leftZ) + if (leftLength <= PLAN_TOLERANCE) return false + const cross = (x: number, z: number) => leftX * z - leftZ * x + if ( + Math.abs(cross(rightStart[0] - leftStart[0], rightStart[1] - leftStart[1])) > + PLAN_TOLERANCE * leftLength || + Math.abs(cross(rightEnd[0] - leftStart[0], rightEnd[1] - leftStart[1])) > + PLAN_TOLERANCE * leftLength + ) { + return false + } + + const project = (point: LeanToPlanPoint) => + ((point[0] - leftStart[0]) * leftX + (point[1] - leftStart[1]) * leftZ) / leftLength + const rightStartDistance = project(rightStart) + const rightEndDistance = project(rightEnd) + const overlapStart = Math.max(0, Math.min(rightStartDistance, rightEndDistance)) + const overlapEnd = Math.min(leftLength, Math.max(rightStartDistance, rightEndDistance)) + return overlapEnd - overlapStart > PLAN_TOLERANCE +} + +function polygonsSharePlanEdge(left: LeanToPlanPoint[], right: LeanToPlanPoint[]): boolean { + return left.some((leftStart, leftIndex) => { + const leftEnd = left[(leftIndex + 1) % left.length]! + return right.some((rightStart, rightIndex) => + planSegmentsShareLength( + leftStart, + leftEnd, + rightStart, + right[(rightIndex + 1) % right.length]!, + ), + ) + }) +} + +function edgeConnectedPlanPolygonComponent( + polygons: LeanToPlanPoint[][], + anchor: LeanToPlanPoint[], +): LeanToPlanPoint[][] { + const connected = new Set() + const queue = polygons.flatMap((polygon, index) => + polygonsSharePlanEdge(anchor, polygon) ? [index] : [], + ) + for (const index of queue) connected.add(index) + + while (queue.length > 0) { + const currentIndex = queue.shift()! + for (let candidateIndex = 0; candidateIndex < polygons.length; candidateIndex++) { + if ( + connected.has(candidateIndex) || + !polygonsSharePlanEdge(polygons[currentIndex]!, polygons[candidateIndex]!) + ) { + continue + } + connected.add(candidateIndex) + queue.push(candidateIndex) + } + } + + return polygons.filter((_, index) => connected.has(index)) +} + function intersectConvexPolygons( subject: readonly LeanToPlanPoint[], clip: readonly LeanToPlanPoint[], @@ -452,6 +767,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, @@ -524,7 +914,10 @@ function resolveConcaveRoofPiece( side: LeanToCornerSide, candidate: LeanToExtensionNode, candidateWall: WallNode, -): { piece: LeanToPlanPoint[]; seam: [LeanToPlanPoint, LeanToPlanPoint] | null } { +): { + piece: LeanToPlanPoint[] + seam: [LeanToPlanPoint, LeanToPlanPoint] | null +} { const layout = resolveLeanToLayout(leanTo) const edges = roofPlanEdges(leanTo) const sideSign = side === 'left' ? -1 : 1 @@ -570,22 +963,180 @@ 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[][] = [] + let shouldUnionPieces = false 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] + shouldUnionPieces ||= joint.mergeRoofPieces === true + 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) + const roofClips = joint.roofPieces + if (roofClips) { + shouldUnionPieces ||= joint.mergeRoofPieces === true + retained = retained.flatMap((subject) => + roofClips.flatMap((clip) => { + const intersection = intersectConvexPolygons(subject, clip) + return intersection.length >= 3 && + Math.abs(polygonSignedArea(intersection)) > PLAN_TOLERANCE + ? [intersection] + : [] + }), + ) + } + additions.push(...(joint.roofAdditionPieces ?? [joint.roofPiece])) } } - return [...(retained.length >= 3 ? [retained] : []), ...additions] + const pieces = [...retained, ...additions] + return shouldUnionPieces ? (unionPolygons(pieces) as LeanToPlanPoint[][]) : pieces } function resolveRoofPiece( @@ -595,7 +1146,10 @@ function resolveRoofPiece( extension: number, candidate: LeanToExtensionNode, candidateWall: WallNode, -): { piece: LeanToPlanPoint[]; seam: [LeanToPlanPoint, LeanToPlanPoint] | null } { +): { + piece: LeanToPlanPoint[] + seam: [LeanToPlanPoint, LeanToPlanPoint] | null +} { const layout = resolveLeanToLayout(leanTo) const edges = roofPlanEdges(leanTo) const sideSign = side === 'left' ? -1 : 1 @@ -641,6 +1195,81 @@ function resolveRoofPiece( } } +function roofExtendedBasePolygon( + leanTo: LeanToExtensionNode, + side: LeanToCornerSide, + extension: number, +): LeanToPlanPoint[] { + const polygon = roofBasePolygon(leanTo) + const layout = resolveLeanToLayout(leanTo) + const sideSign = side === 'left' ? -1 : 1 + const extendedSideX = layout.roofCenterX + sideSign * (layout.roofWidth / 2 + extension) + const sideIndices = side === 'left' ? [0, 3] : [1, 2] + for (const index of sideIndices) polygon[index]![0] = extendedSideX + return polygon +} + +function resolveFreestandingRoofPartition( + leanTo: LeanToExtensionNode, + wall: WallNode, + side: LeanToCornerSide, + kind: LeanToCornerJoint['kind'], + extension: number, + candidate: LeanToExtensionNode, + candidateWall: WallNode, + candidateSide: LeanToCornerSide, + candidateExtension: number, +): { + basePieces: LeanToPlanPoint[][] + additionPieces?: LeanToPlanPoint[][] +} | null { + const layout = resolveLeanToLayout(leanTo) + const edges = roofPlanEdges(leanTo) + const sideSign = side === 'left' ? -1 : 1 + const originalSideX = layout.roofCenterX + sideSign * (layout.roofWidth / 2) + const heightDelta = (point: readonly [number, number]): number | null => { + const worldPoint = leanToPointToWorld(wall, leanTo, point[0], point[1]) + if (!worldPoint) return null + const ownHeight = leanToTopHeightAtWorld(wall, leanTo, worldPoint) + const candidateHeight = leanToTopHeightAtWorld(candidateWall, candidate, worldPoint) + return ownHeight === null || candidateHeight === null ? null : ownHeight - candidateHeight + } + const probeDelta = heightDelta([ + originalSideX - sideSign * Math.min(0.1, layout.roofWidth / 4), + (edges.back + edges.front) / 2, + ]) + if (probeDelta === null || Math.abs(probeDelta) <= PLAN_TOLERANCE) return null + + const candidatePolygon = ( + kind === 'convex' + ? roofExtendedBasePolygon(candidate, candidateSide, candidateExtension) + : roofBasePolygon(candidate) + ).flatMap((point) => { + const worldPoint = leanToPointToWorld(candidateWall, candidate, point[0], point[1]) + const localized = worldPoint && worldPointToLeanTo(wall, leanTo, worldPoint) + return localized ? [localized] : [] + }) + if (candidatePolygon.length < 3) return null + + const partition = (polygon: LeanToPlanPoint[]): LeanToPlanPoint[][] => { + const overlap = intersectConvexPolygons(polygon, candidatePolygon) + const exclusive = subtractConvexPolygon(polygon, candidatePolygon) + const retainedOverlap = clipToRetainedRoofSide(overlap, heightDelta, Math.sign(probeDelta)) + return [...exclusive, retainedOverlap].filter( + (piece) => piece.length >= 3 && Math.abs(polygonSignedArea(piece)) > PLAN_TOLERANCE, + ) + } + + const basePieces = partition(roofBasePolygon(leanTo)) + if (basePieces.length === 0) return null + if (kind === 'concave') return { basePieces } + const additionPieces = edgeConnectedPlanPolygonComponent( + partition(roofExtensionBand(leanTo, side, extension)), + roofBasePolygon(leanTo), + ) + return additionPieces.length > 0 ? { basePieces, additionPieces } : null +} + function resolveCurvedStraightRoofPiece( leanTo: LeanToExtensionNode, wall: WallNode, @@ -650,7 +1279,10 @@ function resolveCurvedStraightRoofPiece( candidateWall: WallNode, candidateSide: LeanToCornerSide, candidateExtension: number, -): { piece: LeanToPlanPoint[]; seam: [LeanToPlanPoint, LeanToPlanPoint] | null } | null { +): { + piece: LeanToPlanPoint[] + seam: [LeanToPlanPoint, LeanToPlanPoint] | null +} | null { const ownCurved = isCurvedLeanTo(leanTo) const candidateCurved = isCurvedLeanTo(candidate) if (ownCurved === candidateCurved) return null @@ -688,8 +1320,17 @@ export function resolveLeanToCornerJoints( wall: WallNode | undefined, nodes: Record | undefined, ): Partial> { - if (!leanTo.autoMiterCorners || !wall || !nodes) return {} - if (!wallFrame(wall)) return {} + if (!nodes) return {} + const sourceLeanTo = leanTo + const freestandingJoints = + sourceLeanTo.hostKind === 'freestanding' + ? resolveFreestandingCanopyJoints(sourceLeanTo, nodes) + : undefined + const ownFrame = resolveLeanToJointFrame(leanTo, wall) + if (!ownFrame || !wallFrame(ownFrame.wall)) return {} + leanTo = ownFrame.leanTo + wall = ownFrame.wall + const cornerLeanTo = applyLeanToWallCornerSpan(leanTo, wall) const tolerance = Math.max( 0.35, (wall.thickness ?? 0.1) + Math.max(leanTo.leftOverhang, leanTo.rightOverhang), @@ -697,116 +1338,236 @@ 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 (candidate.type !== 'lean-to-extension' || candidate.id === sourceLeanTo.id) continue + const storedCandidateWall = candidate.parentId ? nodes[candidate.parentId] : undefined + const candidateFrame = resolveLeanToJointFrame( + candidate, + storedCandidateWall?.type === 'wall' ? storedCandidateWall : undefined, + ) + if (!candidateFrame || candidateFrame.kind !== ownFrame.kind) continue + if ( + ownFrame.kind === 'wall' + ? candidateFrame.wall.parentId !== wall.parentId + : candidate.parentId !== sourceLeanTo.parentId + ) { + continue + } + const candidateWall = candidateFrame.wall if (!wallFrame(candidateWall)) continue - const neighborSide = candidateSideAtPoint(candidateWall, candidate, endpoint, tolerance) + const cornerCandidate = applyLeanToWallCornerSpan(candidateFrame.leanTo, 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 expectedFreestandingJoint = freestandingJoints?.[side] + if ( + ownFrame.kind === 'freestanding' && + expectedFreestandingJoint?.neighborId !== candidate.id + ) { + continue + } + const neighborSide = candidateSideAtPoint(candidateWall, cornerCandidate, endpoint, tolerance) if (!neighborSide) continue + if (expectedFreestandingJoint && expectedFreestandingJoint.neighborSide !== 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, + cornerCandidate, + candidateWall, + neighborSide, + candidateRoofExtension, + ) + : null + const freestandingRoof = + ownFrame.kind === 'freestanding' + ? resolveFreestandingRoofPartition( + cornerLeanTo, wall, side, + kind, 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 resolvedSeam = seam ?? roof.seam + const resolvedRoofPieces = curvedStraightConcaveRoof?.pieces ?? + freestandingRoof?.basePieces ?? [roof.piece] + 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 +1590,17 @@ export function resolveLeanToCornerJoints( neighborSide, roofExtension, roofPiece: roof.piece, - seam: seam ?? roof.seam, + roofPieces: curvedStraightConcaveRoof?.pieces ?? freestandingRoof?.basePieces, + roofAdditionPieces: freestandingRoof?.additionPieces, + mergeRoofPieces: freestandingRoof !== null, + seam: resolvedSeam, + framingRetainedSide: + kind === 'concave' + ? resolveFramingRetainedSide(resolvedSeam, resolvedRoofPieces) + : undefined, 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), @@ -849,7 +1617,10 @@ export function resolveLeanToCornerJoints( export type LeanToCornerJointMetadata = Partial< Record< LeanToCornerSide, - Pick + Pick< + LeanToCornerJoint, + 'beamExtension' | 'gutterMitre' | 'seam' | 'framingRetainedSide' | 'sharedPostOwner' + > > > @@ -864,6 +1635,7 @@ export function leanToCornerJointMetadata( beamExtension: joint.beamExtension, gutterMitre: joint.gutterMitre, seam: joint.seam, + framingRetainedSide: joint.framingRetainedSide, sharedPostOwner: joint.sharedPostOwner, } : undefined, 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..d4e6d958f9 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,34 @@ 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 +325,95 @@ 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 +434,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: 13, schema: LeanToExtensionNode, category: 'structure', snapProfile: 'structural', @@ -282,17 +477,26 @@ 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: 'Esc', label: 'Cancel' }, + { + key: 'Left click', + label: 'Place canopy or set the next run point', + }, + { key: 'R / T', label: 'Rotate or flip the run side' }, + { key: 'F', label: 'Cycle mono / gable / butterfly' }, + { key: 'Esc', label: 'End run / cancel' }, ], presentation: { - label: 'Lean-to Extension', - description: 'An open mono-pitch roof attached to a wall and supported by a pillar row.', + label: 'Canopy', + description: + 'An attached mono-pitch or freestanding mono, gable, or butterfly canopy with managed structure and drainage.', icon: { kind: 'url', src: '/icons/lean-to-extension.webp' }, paletteSection: 'structure', paletteGroup: 'roof-features', @@ -300,6 +504,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..4835ca81bd 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx +++ b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx @@ -1,32 +1,44 @@ 'use client' -import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, LeanToExtensionNode } from '@pascal-app/core' import { getWallCurveFrameAt, getWallCurveLength, isCurvedWall } from '@pascal-app/core' import { type FloorplanToolContext, + getSegmentGridStep, + isGridSnapActive, + isMagneticSnapActive, 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' + LEAN_TO_RUN_CONNECT_SNAP_RADIUS, + LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS, + type LeanToPlanPlacementTarget, + nextLeanToCanopyForm, + nextLeanToPlacementRotation, + resolveLeanToCommitTarget, + resolveLeanToFreestandingRunEndpointSnap, + resolveLeanToFreestandingRunTarget, + 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 +54,16 @@ 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 formRef = useRef('mono') + const chainStartRef = useRef(null) + const chainEndRef = useRef(null) + const chainEndSnappedRef = useRef(false) + const chainFlipRef = useRef(false) + const [target, setTarget] = useState(null) + const [chainAnchor, setChainAnchor] = useState(null) + const [runSnap, setRunSnap] = useState(null) const clearTarget = useCallback(() => { targetRef.current = null @@ -56,6 +76,35 @@ const FloorplanLeanToExtensionTool = ({ const svg = group?.ownerSVGElement if (!(group && svg)) return useInteractionScope.getState().begin({ kind: 'drafting', tool: 'lean-to-extension' }) + rotationRef.current = 0 + formRef.current = 'mono' + chainStartRef.current = null + chainEndRef.current = null + chainEndSnappedRef.current = false + chainFlipRef.current = false + let lastFreestandingEvent: PointerEvent | null = null + let lastRunSnapKey: string | null = null + + const isContinuous = () => useEditor.getState().getContinuation('canopy') === 'continuous' + + const snappedEventPoint = (event: MouseEvent | PointerEvent): PlanPoint | null => { + const point = clientToPlanPoint(group, event.clientX, event.clientY) + if (!point) return null + const step = !event.altKey && isGridSnapActive() ? getSegmentGridStep() : 0 + const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) + return [snap(point[0]), snap(point[1])] + } + + const finishRun = () => { + chainStartRef.current = null + chainEndRef.current = null + chainEndSnappedRef.current = false + chainFlipRef.current = false + setChainAnchor(null) + setRunSnap(null) + lastRunSnapKey = null + clearTarget() + } const consume = (event: Event) => { event.preventDefault() @@ -65,31 +114,64 @@ 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, + if (chainStartRef.current && isContinuous()) { + const proposedEnd = snappedEventPoint(event) + if (!proposedEnd) return null + const snap = event.altKey + ? null + : resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId, + canopyForm: formRef.current, + flipProjection: chainFlipRef.current, + maxDistance: isMagneticSnapActive() + ? LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS + : LEAN_TO_RUN_CONNECT_SNAP_RADIUS, + nodes, + proposedEnd, + start: chainStartRef.current, + }) + const snapKey = snap ? `${snap.nodeId}:${snap.side}` : null + if (snapKey && snapKey !== lastRunSnapKey) triggerSFX('sfx:grid-snap') + lastRunSnapKey = snapKey + setRunSnap(snap?.point ?? null) + const end = snap?.point ?? proposedEnd + chainEndRef.current = end + chainEndSnappedRef.current = Boolean(snap) + return resolveLeanToFreestandingRunTarget({ + activeLevelId, + canopyForm: formRef.current, + start: chainStartRef.current, + end, + flipProjection: chainFlipRef.current, + nodes, + }) + } + if (chainStartRef.current) finishRun() + const conicalHost = findConicalLeanToHostInPlan(point, nodes, activeLevelId, { + includeOccupied: true, + }) + if (conicalHost) { + return { + node: conicalHost.node, + valid: !isConicalLeanToHostOccupied(conicalHost.segment.id, nodes), + conicalHost, + } + } + const snappedPoint = snappedEventPoint(event) ?? point + return resolveLeanToPlanPlacement({ + activeLevelId, + freestandingPoint: snappedPoint, + freestandingRotationY: rotationRef.current, + freestandingCanopyForm: formRef.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 +181,23 @@ const FloorplanLeanToExtensionTool = ({ const commit = (event: MouseEvent) => { if (event.button !== 0) return consume(event) - const node = resolveEvent(event) ?? targetRef.current - if (!node) return + const clicked = resolveEvent(event) + if (isContinuous() && !chainStartRef.current && clicked?.node.hostKind === 'freestanding') { + const point = snappedEventPoint(event) + if (!point) return + chainStartRef.current = point + chainEndRef.current = null + chainEndSnappedRef.current = false + setChainAnchor(point) + clearTarget() + triggerSFX('sfx:structure-build-start') + return + } + const resolved = resolveLeanToCommitTarget(targetRef.current, clicked) + if (!resolved?.valid) return + const committedEnd = chainEndRef.current + const closesLoop = chainEndSnappedRef.current + const { node } = resolved const nodes = sceneApi.nodes() as Record const assembly = createLeanToAssembly(node, resolveLeanToHostRoof(node, nodes), nodes) sceneApi.createMany?.([ @@ -112,27 +209,90 @@ const FloorplanLeanToExtensionTool = ({ ]) selectNode(assembly.extension.id) triggerSFX('sfx:structure-build') - if (useEditor.getState().getContinuation('point') !== 'repeat') finishTool() + if (chainStartRef.current) { + if (closesLoop) { + finishRun() + return + } + if (committedEnd) { + chainStartRef.current = committedEnd + chainEndRef.current = null + chainEndSnappedRef.current = false + setChainAnchor(committedEnd) + } + setRunSnap(null) + lastRunSnapKey = null + clearTarget() + } else if (!isContinuous()) { + finishTool() + } } - const cancel = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopImmediatePropagation() + markToolCancelConsumed() + if (chainStartRef.current) finishRun() + else finishTool() + return + } + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return + } + if ( + chainStartRef.current && + (event.key === 'r' || event.key === 'R' || event.key === 't' || event.key === 'T') + ) { + event.preventDefault() + chainFlipRef.current = !chainFlipRef.current + triggerSFX('sfx:item-rotate') + if (lastFreestandingEvent) { + const resolved = resolveEvent(lastFreestandingEvent) + targetRef.current = resolved + setTarget(resolved) + } + return + } + const nextRotation = nextLeanToPlacementRotation( + rotationRef.current, + event.key, + event.metaKey || event.ctrlKey, + ) + const nextForm = nextLeanToCanopyForm(formRef.current, event.key) + if (nextRotation === rotationRef.current && nextForm === formRef.current) return + event.preventDefault() - event.stopImmediatePropagation() - markToolCancelConsumed() - finishTool() + rotationRef.current = nextRotation + formRef.current = nextForm + triggerSFX('sfx:item-rotate') + if (lastFreestandingEvent) { + const resolved = resolveEvent(lastFreestandingEvent) + targetRef.current = resolved + setTarget(resolved) + } + } + const onPointerLeave = (event: PointerEvent) => { + lastFreestandingEvent = null + clearTarget() + setChainAnchor(null) + setRunSnap(null) } 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 +301,118 @@ 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 + const anchorMarker = chainAnchor ? ( + + ) : null + const snapMarker = runSnap ? ( + + ) : null + 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 ( + + {anchorMarker} + {snapMarker} + 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 back = + node.canopyForm === 'gable' || node.canopyForm === 'butterfly' + ? -(node.projection + node.lowOverhang) + : -node.highOverhang + const points = [ + toWorld(-(node.span / 2 + node.leftOverhang), back), + toWorld(node.span / 2 + node.rightOverhang, back), + toWorld(node.span / 2 + node.rightOverhang, node.projection + node.lowOverhang), + toWorld(-(node.span / 2 + node.leftOverhang), node.projection + node.lowOverhang), + ] + return ( + + {anchorMarker} + {snapMarker} + point.join(',')).join(' ')} + stroke={target.valid ? '#0ea5e9' : '#ef4444'} + strokeDasharray="6 4" + strokeWidth={2} + vectorEffect="non-scaling-stroke" + /> + + ) + } + if (!(node && wall?.type === 'wall')) { + return ( + + {anchorMarker} + {snapMarker} + + ) + } - 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 +426,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 +442,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 +462,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][] = [] @@ -215,11 +478,13 @@ const FloorplanLeanToExtensionTool = ({ return ( + {anchorMarker} + {snapMarker} 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..79e65b472d 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.test.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.test.ts @@ -3,12 +3,51 @@ 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' +import { resolveLeanToFreestandingRunPlacement } from './placement' 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 +76,172 @@ 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) + }) + + test('draws a gable canopy symmetrically around its ridge', () => { + const level = LevelNode.parse({ id: 'level_gable_canopy', level: 0 }) + const node = LeanToExtensionNode.parse({ + parentId: level.id, + canopyForm: 'gable', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + span: 4, + projection: 3, + lowOverhang: 0.25, + 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[1]))).toBeCloseTo(-3.25) + expect(Math.max(...roof.points.map((point) => point[1]))).toBeCloseTo(3.25) + expect(geometry.children.filter((child) => child.kind === 'polyline')).toHaveLength(2) + }) + + test('draws a butterfly canopy with the same symmetric two-row footprint', () => { + const level = LevelNode.parse({ id: 'level_butterfly_canopy', level: 0 }) + const node = LeanToExtensionNode.parse({ + parentId: level.id, + canopyForm: 'butterfly', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + projection: 3, + lowOverhang: 0.25, + }) + 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[1]))).toBeCloseTo(-3.25) + expect(Math.max(...roof.points.map((point) => point[1]))).toBeCloseTo(3.25) + expect(geometry.children.filter((child) => child.kind === 'polyline')).toHaveLength(2) + expect(geometry.children.filter((child) => child.kind === 'rect')).toHaveLength(6) + }) + + test.each([ + 'gable', + 'butterfly', + ] as const)('draws the continuous %s roof footprint to the shared diagonal seam', (canopyForm) => { + const level = LevelNode.parse({ id: `level_${canopyForm}_floorplan_joint`, level: 0 }) + const first = resolveLeanToFreestandingRunPlacement( + level.id, + [0, 0], + [4, 0], + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + [4, 0], + [4, 4], + false, + canopyForm, + )! + const geometry = buildLeanToExtensionFloorplan(first, { + children: [], + parent: level, + resolve: () => undefined, + siblings: [second], + } 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 + const run = first.projection + first.lowOverhang + expect(roof.points).toContainEqual([4, 0]) + expect( + roof.points.some(([x, z]) => Math.abs(x - (4 - run)) < 1e-8 && Math.abs(z - run) < 1e-8), + ).toBe(true) + expect( + roof.points.some(([x, z]) => Math.abs(x - (4 + run)) < 1e-8 && Math.abs(z + run) < 1e-8), + ).toBe(true) + expect(roof.points).not.toContainEqual([4 + first.rightOverhang, run]) + }) + + test('draws the continuous mono roof footprint to the shared diagonal seam', () => { + const level = LevelNode.parse({ id: 'level_mono_floorplan_joint', level: 0 }) + const first = resolveLeanToFreestandingRunPlacement(level.id, [0, 0], [4, 0], false, 'mono')! + const second = resolveLeanToFreestandingRunPlacement(level.id, [4, 0], [4, 4], false, 'mono')! + const geometry = buildLeanToExtensionFloorplan(first, { + children: [], + parent: level, + resolve: () => undefined, + siblings: [second], + } 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 + const lowEdge = first.projection + first.lowOverhang + const highEdge = first.highOverhang + expect( + roof.points.some( + ([x, z]) => Math.abs(x - (4 - lowEdge)) < 1e-8 && Math.abs(z - lowEdge) < 1e-8, + ), + ).toBe(true) + expect( + roof.points.some( + ([x, z]) => Math.abs(x - (4 + highEdge)) < 1e-8 && Math.abs(z + highEdge) < 1e-8, + ), + ).toBe(true) + expect(roof.points).not.toContainEqual([4 + first.rightOverhang, lowEdge]) + expect(roof.points).not.toContainEqual([4 + first.rightOverhang, -highEdge]) + }) }) diff --git a/packages/nodes/src/lean-to-extension/floorplan.ts b/packages/nodes/src/lean-to-extension/floorplan.ts index b56f4c814f..cd1886e26c 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.ts @@ -1,4 +1,6 @@ import { + type AnyNode, + type AnyNodeId, type FloorplanGeometry, type FloorplanPoint, type GeometryContext, @@ -6,16 +8,262 @@ import { getWallCurveLength, isCurvedWall, type LeanToExtensionNode, + type RoofNode, + type RoofSegmentNode, type WallNode, } from '@pascal-app/core' import { bendLocalPoint, isCurvedLeanTo } from './arc' +import { resolveFreestandingCanopyJoints } from './canopy-joint' import { leanToFacetCount } from './geometry' -import { resolveLeanToLayout } from './layout' +import { isDualSlopeLeanToCanopy, 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 = isDualSlopeLeanToCanopy(layout.canopyForm) + ? layout.projection + node.lowOverhang + : node.highOverhang + const low = layout.projection + node.lowOverhang + const canopyJoints = resolveFreestandingCanopyJoints( + node, + Object.fromEntries( + [node, ...ctx.siblings].map((candidate) => [candidate.id, candidate]), + ) as Record, + ) + const edgeXAtZ = (side: 'left' | 'right', z: number) => { + const joint = canopyJoints[side] + if (!joint) return side === 'left' ? -left : right + const structuralX = side === 'left' ? -layout.span / 2 : layout.span / 2 + if (joint.kind === 'linear') return structuralX + const inwardSign = side === 'left' ? 1 : -1 + const innerSideSign = joint.innerCanopySide === 'positive' ? 1 : -1 + return structuralX + inwardSign * innerSideSign * (z / joint.trimZ) * joint.trimX + } + const points: FloorplanPoint[] = isDualSlopeLeanToCanopy(layout.canopyForm) + ? [ + toWorld(edgeXAtZ('left', -high), -high), + toWorld(edgeXAtZ('right', -high), -high), + ...(canopyJoints.right ? [toWorld(layout.span / 2, 0)] : []), + toWorld(edgeXAtZ('right', low), low), + toWorld(edgeXAtZ('left', low), low), + ...(canopyJoints.left ? [toWorld(-layout.span / 2, 0)] : []), + ] + : [ + toWorld(edgeXAtZ('left', -high), -high), + toWorld(edgeXAtZ('right', -high), -high), + toWorld(edgeXAtZ('right', low), low), + toWorld(edgeXAtZ('left', low), 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', + }, + ] + if (isDualSlopeLeanToCanopy(layout.canopyForm)) { + children.push({ + kind: 'polyline', + points: [ + toWorld(-layout.beamSpan / 2, layout.oppositeBeamZ), + toWorld(layout.beamSpan / 2, layout.oppositeBeamZ), + ], + 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(isDualSlopeLeanToCanopy(layout.canopyForm) ? layout.oppositeBeamZ : 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 +366,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/geometry.test.ts b/packages/nodes/src/lean-to-extension/geometry.test.ts index faa90a25cb..2747c93133 100644 --- a/packages/nodes/src/lean-to-extension/geometry.test.ts +++ b/packages/nodes/src/lean-to-extension/geometry.test.ts @@ -1,11 +1,20 @@ import { describe, expect, test } from 'bun:test' import { LeanToExtensionNode } from '@pascal-app/core' -import { resolveSurfaceColor } from '@pascal-app/viewer' -import { Box3, type BoxGeometry, type Mesh, type MeshStandardMaterial, Vector3 } from 'three' +import { generateRoofSegmentGeometry, resolveSurfaceColor } from '@pascal-app/viewer' +import { + Box3, + type BoxGeometry, + Matrix4, + Mesh, + type MeshStandardMaterial, + Raycaster, + Vector3, +} from 'three' import { buildGutterGeometry } from '../gutter/geometry' import { createLeanToAssembly } from './assembly' import { buildLeanToExtensionGeometry } from './geometry' import { resolveLeanToLayout } from './layout' +import { resolveLeanToFreestandingRunPlacement } from './placement' import { leanToSlots } from './slots' describe('lean-to extension geometry', () => { @@ -318,6 +327,41 @@ describe('lean-to extension geometry', () => { } }) + test('clips purlins to the front-retained half of a continuous shed seam', () => { + const seam = [ + [2, 0], + [-0.75, 2.75], + ] as const + const node = LeanToExtensionNode.parse({ + span: 4, + leftOverhang: 0, + rightOverhang: 0, + framingStrategy: 'purlins', + metadata: { + leanToCornerJoints: { + right: { + beamExtension: -2.5, + gutterMitre: -Math.PI / 4, + seam, + framingRetainedSide: 'front', + sharedPostOwner: true, + }, + }, + }, + }) + const purlins = buildLeanToExtensionGeometry(node, {} as never).children.filter( + (child): child is Mesh => child.name.startsWith('lean-to-purlin-'), + ) + + for (const purlin of purlins) { + const ratio = (purlin.position.z - seam[0][1]) / (seam[1][1] - seam[0][1]) + if (ratio < 0 || ratio > 1) continue + const seamX = seam[0][0] + (seam[1][0] - seam[0][0]) * ratio + const width = (purlin.geometry.parameters as { width: number }).width + expect(purlin.position.x - width / 2).toBeGreaterThanOrEqual(seamX - 1e-6) + } + }) + test('cuts an extended corner beam at the resolved arbitrary mitre angle', () => { const node = LeanToExtensionNode.parse({ span: 4, @@ -347,4 +391,98 @@ describe('lean-to extension geometry', () => { 6, ) }) + + test('keeps framing inside the roof footprint for both continuous shed turns', () => { + for (const turnZ of [-4, 4]) { + const first = resolveLeanToFreestandingRunPlacement('level_shed_framing', [0, 0], [4, 0])! + const second = resolveLeanToFreestandingRunPlacement( + 'level_shed_framing', + [4, 0], + [4, turnZ], + )! + const nodes = { [first.id]: first, [second.id]: second } + const runs = [first, second] + const assemblies = runs.map((run) => createLeanToAssembly(run, undefined, nodes)) + const roofMeshes = assemblies.map((assembly, index) => { + const run = runs[index]! + const matrix = new Matrix4() + .makeTranslation(...run.position) + .multiply(new Matrix4().makeRotationY(run.rotation[1])) + .multiply(new Matrix4().makeTranslation(...assembly.segment.position)) + .multiply(new Matrix4().makeRotationY(assembly.segment.rotation)) + return new Mesh(generateRoofSegmentGeometry(assembly.segment).applyMatrix4(matrix)) + }) + const raycaster = new Raycaster() + raycaster.ray.direction.set(0, -1, 0) + const exposedSamples: string[] = [] + + for (const [index, assembly] of assemblies.entries()) { + const run = runs[index]! + const framing = buildLeanToExtensionGeometry(assembly.extension, {} as never) + framing.applyMatrix4( + new Matrix4() + .makeTranslation(...run.position) + .multiply(new Matrix4().makeRotationY(run.rotation[1])), + ) + framing.updateMatrixWorld(true) + for (const member of framing.children.filter((child): child is Mesh => + /^lean-to-rafter-\d+$/.test(child.name), + )) { + const { depth } = member.geometry.parameters as { depth: number } + for (const z of [-depth * 0.35, 0, depth * 0.35]) { + const point = member.localToWorld(new Vector3(0, 0, z)) + raycaster.ray.origin.set(point.x, 10, point.z) + const coverY = Math.max( + ...roofMeshes.flatMap((roof) => + raycaster.intersectObject(roof, false).map((hit) => hit.point.y), + ), + ) + if (!Number.isFinite(coverY) || coverY <= point.y) { + exposedSamples.push( + `${turnZ}:${index}:${member.name}:${point.x.toFixed(3)}:${point.y.toFixed(3)}:${point.z.toFixed(3)}:${coverY.toFixed(3)}`, + ) + } + } + } + } + + expect(exposedSamples).toEqual([]) + for (const roof of roofMeshes) roof.geometry.dispose() + } + }) + + test('builds mirrored roof planes, framing, and eave beams for a gable canopy', () => { + const node = LeanToExtensionNode.parse({ + canopyForm: 'gable', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + }) + const group = buildLeanToExtensionGeometry(node) + + expect(group.getObjectByName('lean-to-preview-roof')).toBeDefined() + expect(group.getObjectByName('lean-to-preview-roof-opposite')).toBeDefined() + expect(group.getObjectByName('lean-to-front-beam')).toBeDefined() + expect(group.getObjectByName('lean-to-opposite-beam')).toBeDefined() + expect(group.getObjectByName('lean-to-rafter-0')).toBeDefined() + expect(group.getObjectByName('lean-to-opposite-rafter-0')).toBeDefined() + expect(group.getObjectByName('lean-to-high-post-0')?.position.z).toBeLessThan(0) + }) + + test('slopes both butterfly roof planes and rafters inward toward the valley', () => { + const node = LeanToExtensionNode.parse({ + canopyForm: 'butterfly', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + }) + const group = buildLeanToExtensionGeometry(node) + const rightRoof = group.getObjectByName('lean-to-preview-roof') + const leftRoof = group.getObjectByName('lean-to-preview-roof-opposite') + + expect(rightRoof?.rotation.x).toBeLessThan(0) + expect(leftRoof?.rotation.x).toBeGreaterThan(0) + expect(group.getObjectByName('lean-to-opposite-beam')).toBeDefined() + expect(group.getObjectByName('lean-to-independent-high-beam')).toBeUndefined() + expect(group.getObjectByName('lean-to-rafter-0')?.rotation.x).toBeLessThan(0) + expect(group.getObjectByName('lean-to-opposite-rafter-0')?.rotation.x).toBeGreaterThan(0) + }) }) diff --git a/packages/nodes/src/lean-to-extension/geometry.ts b/packages/nodes/src/lean-to-extension/geometry.ts index 1888d64dfd..221658330d 100644 --- a/packages/nodes/src/lean-to-extension/geometry.ts +++ b/packages/nodes/src/lean-to-extension/geometry.ts @@ -9,8 +9,13 @@ import { } from '@pascal-app/viewer' import { BoxGeometry, FrontSide, Group, type Material, Mesh, Quaternion, Vector3 } from 'three' import { bendLocalPoint, bendRotationYAtLocalX, isCurvedLeanTo } from './arc' +import { type CanopySide, readFreestandingCanopyJointMetadata } from './canopy-joint' import { readLeanToCornerJointMetadata } from './corner-joint' -import { LEAN_TO_EXTENSION_GEOMETRY_REVISION, resolveLeanToLayout } from './layout' +import { + isDualSlopeLeanToCanopy, + LEAN_TO_EXTENSION_GEOMETRY_REVISION, + resolveLeanToLayout, +} from './layout' import { LEAN_TO_SLOT_DEFAULTS, type LeanToSlotId } from './slots' // Number of straight facets used to approximate a curved member spanning the arc. @@ -22,6 +27,7 @@ export function leanToFacetCount(node: LeanToExtensionNode): number { export function leanToExtensionGeometryKey(node: LeanToExtensionNode): string { return JSON.stringify([ LEAN_TO_EXTENSION_GEOMETRY_REVISION, + node.canopyForm, node.span, node.spanArcCenterZ, node.spanArcRadius, @@ -64,6 +70,7 @@ export function leanToExtensionGeometryKey(node: LeanToExtensionNode): string { node.leftEndCondition, node.rightEndCondition, readLeanToCornerJointMetadata(node), + readFreestandingCanopyJointMetadata(node), ]) } @@ -151,6 +158,7 @@ function addMiteredBeam( colorPreset: ColorPreset sceneTheme?: string material: Material + name?: string }, ) { const length = args.maxX - args.minX @@ -172,7 +180,7 @@ function addMiteredBeam( positions.needsUpdate = true geometry.computeVertexNormals() const mesh = new Mesh(geometry, args.material) - mesh.name = 'lean-to-front-beam' + mesh.name = args.name ?? 'lean-to-front-beam' mesh.position.set(centerX, args.y, args.z) mesh.castShadow = true mesh.receiveShadow = true @@ -211,7 +219,12 @@ export function buildLeanToExtensionGeometry( sceneTheme?: string, ): Group { const layout = resolveLeanToLayout(node) + const butterfly = layout.canopyForm === 'butterfly' + const dualSlope = isDualSlopeLeanToCanopy(layout.canopyForm) + const primarySlope = butterfly ? -layout.pitchRadians : layout.pitchRadians + const oppositeSlope = -primarySlope const cornerJoints = readLeanToCornerJointMetadata(node) + const canopyJoints = readFreestandingCanopyJointMetadata(node) const group = new Group() group.name = 'lean-to-extension-geometry' @@ -236,6 +249,7 @@ export function buildLeanToExtensionGeometry( return { z: start[1] + (end[1] - start[1]) * ratio, dzDx: (end[1] - start[1]) / deltaX, + retainedSide: cornerJoints[side]?.framingRetainedSide ?? 'back', } } const retainedWidthAtZ = (z: number) => { @@ -250,6 +264,48 @@ export function buildLeanToExtensionGeometry( const ratio = (z - start[1]) / deltaZ if (ratio < -1e-6 || ratio > 1 + 1e-6) continue const seamX = start[0] + (end[0] - start[0]) * ratio + const deltaX = end[0] - start[0] + if (Math.abs(deltaX) <= 1e-6) continue + const retainedSide = cornerJoints[side]?.framingRetainedSide ?? 'back' + const slope = deltaZ / deltaX + const retainGreaterX = retainedSide === 'front' ? slope < 0 : slope > 0 + if (retainGreaterX) minX = Math.max(minX, seamX) + else maxX = Math.min(maxX, seamX) + } + return { minX, maxX } + } + const canopySeamIntersectionsAtX = (planeSide: CanopySide, x: number) => { + const intersections: Array<{ z: number; dzDx: number }> = [] + for (const [side, joint] of Object.entries(canopyJoints) as [ + 'left' | 'right', + NonNullable<(typeof canopyJoints)['left' | 'right']>, + ][]) { + if (joint.kind !== 'corner' || joint.innerCanopySide !== planeSide) continue + const endpointX = side === 'left' ? -layout.span / 2 : layout.span / 2 + const seamEndX = endpointX + (side === 'left' ? 1 : -1) * joint.trimX + const seamEndZ = (planeSide === 'positive' ? 1 : -1) * joint.trimZ + const deltaX = seamEndX - endpointX + if (Math.abs(deltaX) <= 1e-6) continue + const ratio = (x - endpointX) / deltaX + if (ratio < -1e-6 || ratio > 1 + 1e-6) continue + intersections.push({ z: seamEndZ * ratio, dzDx: seamEndZ / deltaX }) + } + return intersections + } + const retainedCanopyWidthAtZ = (planeSide: CanopySide, z: number) => { + let minX = layout.roofCenterX - layout.roofWidth / 2 + let maxX = layout.roofCenterX + layout.roofWidth / 2 + for (const [side, joint] of Object.entries(canopyJoints) as [ + 'left' | 'right', + NonNullable<(typeof canopyJoints)['left' | 'right']>, + ][]) { + if (joint.kind !== 'corner' || joint.innerCanopySide !== planeSide || joint.trimZ <= 1e-6) { + continue + } + const ratio = Math.abs(z) / joint.trimZ + if (ratio < -1e-6 || ratio > 1 + 1e-6) continue + const endpointX = side === 'left' ? -layout.span / 2 : layout.span / 2 + const seamX = endpointX + (side === 'left' ? 1 : -1) * joint.trimX * ratio if (side === 'left') minX = Math.max(minX, seamX) else maxX = Math.min(maxX, seamX) } @@ -399,12 +455,25 @@ export function buildLeanToExtensionGeometry( depth: layout.slopeLength, localZ: layout.roofCenterZ, y: layout.roofCenterY, - rotationX: layout.pitchRadians, + rotationX: primarySlope, role: 'roof', }) + if (dualSlope) { + addBentStrip({ + name: 'lean-to-preview-roof-opposite', + centerX: layout.roofCenterX, + totalWidth: layout.roofWidth, + height: node.roofThickness, + depth: layout.slopeLength, + localZ: -layout.roofCenterZ, + y: layout.roofCenterY, + rotationX: oppositeSlope, + role: 'roof', + }) + } } - if (node.highSideMode === 'independent-high-beam') { + if (node.highSideMode === 'independent-high-beam' && !butterfly) { addBentStrip({ name: 'lean-to-independent-high-beam', centerX: 0, @@ -481,6 +550,24 @@ export function buildLeanToExtensionGeometry( sceneTheme, material: beamMaterial, }) + if (dualSlope) { + addMiteredBeam(group, { + minX: beamMinX, + maxX: beamMaxX, + leftMiterCenter: null, + rightMiterCenter: null, + leftMiterSlope: 0, + rightMiterSlope: 0, + height: node.beamHeight, + depth: node.beamWidth, + y: layout.beamCenterY, + z: layout.oppositeBeamZ, + colorPreset, + sceneTheme, + material: beamMaterial, + name: 'lean-to-opposite-beam', + }) + } } if (!ctx) { @@ -511,19 +598,21 @@ export function buildLeanToExtensionGeometry( } if (!ctx && node.highSideMode === 'independent-high-beam') { - const highPostHeight = Math.max( - 0.2, - layout.highEdgeHeight - - node.roofThickness / 2 - - node.ledgerHeight + - node.ledgerVerticalOffset, - ) + const highPostHeight = dualSlope + ? layout.postHeight + : Math.max( + 0.2, + layout.highEdgeHeight - + node.roofThickness / 2 - + node.ledgerHeight + + node.ledgerVerticalOffset, + ) for (const [index, x] of layout.postXs.entries()) { addBentBox({ name: `lean-to-high-post-${index}`, size: [node.postWidth, highPostHeight, node.postDepth], localX: x, - localZ: 0, + localZ: dualSlope ? layout.oppositeBeamZ : 0, y: highPostHeight / 2, role: 'joinery', material: postsMaterial, @@ -534,7 +623,7 @@ export function buildLeanToExtensionGeometry( name: `lean-to-high-post-footing-${index}`, size: [node.postWidth * footingScale, footingHeight, node.postDepth * footingScale], localX: x, - localZ: 0, + localZ: dualSlope ? layout.oppositeBeamZ : 0, y: footingHeight / 2, role: 'joinery', material: footingsMaterial, @@ -558,6 +647,19 @@ export function buildLeanToExtensionGeometry( material: framingMaterial, slotId: 'framing', }) + if (dualSlope) { + addBentBox({ + name: `lean-to-opposite-knee-brace-${index}`, + size: [node.rafterWidth, node.rafterHeight, Math.min(0.8, layout.projection / 2)], + localX: x, + localZ: Math.min(0, layout.oppositeBeamZ + 0.22), + y: layout.beamCenterY - 0.22, + rotationX: -Math.PI / 4, + role: 'joinery', + material: framingMaterial, + slotId: 'framing', + }) + } } } @@ -566,56 +668,107 @@ export function buildLeanToExtensionGeometry( node.roofThickness / Math.max(0.1, Math.cos(layout.pitchRadians)) + (node.shingleThickness ?? 0.025) * Math.cos(layout.pitchRadians) const rafterY = (z: number) => - layout.highEdgeHeight - - z * Math.tan(layout.pitchRadians) - + (butterfly + ? layout.lowEdgeHeight + Math.abs(z) * Math.tan(layout.pitchRadians) + : layout.highEdgeHeight - Math.abs(z) * Math.tan(layout.pitchRadians)) - roofBuildUp - node.rafterHeight / 2 const halfRafterRun = (layout.rafterSlopeLength * Math.cos(layout.pitchRadians)) / 2 const rafterBackZ = layout.rafterCenterZ - halfRafterRun const rafterFrontZ = layout.rafterCenterZ + halfRafterRun + const addRafter = ( + name: string, + x: number, + backZ: number, + frontZ: number, + centerZ: number, + rotationX: number, + ) => { + if (frontZ <= backZ + 1e-6) return + const expectedBackZ = centerZ - halfRafterRun + const expectedFrontZ = centerZ + halfRafterRun + if (backZ > expectedBackZ + 1e-6 || frontZ < expectedFrontZ - 1e-6) { + addBoxBetween(group, { + name, + start: [x, rafterY(backZ), backZ], + end: [x, rafterY(frontZ), frontZ], + width: node.rafterWidth, + height: node.rafterHeight, + role: 'joinery', + colorPreset, + sceneTheme, + material: framingMaterial, + slotId: 'framing', + }) + return + } + addBentBox({ + name, + size: [node.rafterWidth, node.rafterHeight, layout.rafterSlopeLength], + localX: x, + localZ: centerZ, + y: layout.rafterCenterY, + rotationX, + role: 'joinery', + material: framingMaterial, + slotId: 'framing', + }) + } for (const [index, x] of layout.rafterXs.entries()) { if (cornerJoints.left && index === 0) continue if (cornerJoints.right && index === layout.rafterXs.length - 1) continue + let clippedBackZ = rafterBackZ let clippedFrontZ = rafterFrontZ for (const side of ['left', 'right'] as const) { const intersection = seamIntersectionAtX(side, x) if (!intersection) continue + const endRetreat = + (Math.abs(intersection.dzDx) * node.rafterWidth) / 2 + + (Math.sin(layout.pitchRadians) * node.rafterHeight) / 2 + + 0.002 + if (intersection.retainedSide === 'front') { + clippedBackZ = Math.max(clippedBackZ, intersection.z + endRetreat) + } else { + clippedFrontZ = Math.min(clippedFrontZ, intersection.z - endRetreat) + } + } + for (const intersection of canopySeamIntersectionsAtX('positive', x)) { const endRetreat = (Math.abs(intersection.dzDx) * node.rafterWidth) / 2 + (Math.sin(layout.pitchRadians) * node.rafterHeight) / 2 + 0.002 clippedFrontZ = Math.min(clippedFrontZ, intersection.z - endRetreat) } - if (clippedFrontZ <= rafterBackZ + 1e-6) continue - if (clippedFrontZ < rafterFrontZ - 1e-6) { - addBoxBetween(group, { - name: `lean-to-rafter-${index}`, - start: [x, rafterY(rafterBackZ), rafterBackZ], - end: [x, rafterY(clippedFrontZ), clippedFrontZ], - width: node.rafterWidth, - height: node.rafterHeight, - role: 'joinery', - colorPreset, - sceneTheme, - material: framingMaterial, - slotId: 'framing', - }) - } else { - addBentBox({ - name: `lean-to-rafter-${index}`, - size: [node.rafterWidth, node.rafterHeight, layout.rafterSlopeLength], - localX: x, - localZ: layout.rafterCenterZ, - y: layout.rafterCenterY, - rotationX: layout.pitchRadians, - role: 'joinery', - material: framingMaterial, - slotId: 'framing', - }) + addRafter( + `lean-to-rafter-${index}`, + x, + clippedBackZ, + clippedFrontZ, + layout.rafterCenterZ, + primarySlope, + ) + if (dualSlope) { + let oppositeBackZ = -rafterFrontZ + const oppositeFrontZ = -rafterBackZ + for (const intersection of canopySeamIntersectionsAtX('negative', x)) { + const endRetreat = + (Math.abs(intersection.dzDx) * node.rafterWidth) / 2 + + (Math.sin(layout.pitchRadians) * node.rafterHeight) / 2 + + 0.002 + oppositeBackZ = Math.max(oppositeBackZ, intersection.z + endRetreat) + } + addRafter( + `lean-to-opposite-rafter-${index}`, + x, + oppositeBackZ, + oppositeFrontZ, + -layout.rafterCenterZ, + oppositeSlope, + ) } } for (const [side, joint] of Object.entries(cornerJoints)) { - if (!(joint?.sharedPostOwner && joint.seam)) continue + if (!(joint?.sharedPostOwner && joint.seam) || node.hostKind === 'freestanding') continue const [start, end] = joint.seam const [startX, startZ] = bend(start[0], start[1]) const [endX, endZ] = bend(end[0], end[1]) @@ -642,22 +795,49 @@ export function buildLeanToExtensionGeometry( for (let index = 0; index < count; index++) { const fraction = index / (count - 1) const z = fraction * layout.rafterCenterZ * 2 - const y = layout.rafterCenterY + (layout.rafterCenterZ - z) * Math.tan(layout.pitchRadians) + const y = + layout.rafterCenterY + + (butterfly ? z - layout.rafterCenterZ : layout.rafterCenterZ - z) * + Math.tan(layout.pitchRadians) const retained = retainedWidthAtZ(z) - if (retained.maxX <= retained.minX + 1e-6) continue - addBentStrip({ - name: `lean-to-purlin-${index}`, - centerX: (retained.minX + retained.maxX) / 2, - totalWidth: retained.maxX - retained.minX, - height: node.purlinHeight, - depth: node.purlinWidth, - localZ: z, - y, - rotationX: layout.pitchRadians, - role: 'joinery', - material: framingMaterial, - slotId: 'framing', - }) + const positiveRetained = retainedCanopyWidthAtZ('positive', z) + const primaryMinX = Math.max(retained.minX, positiveRetained.minX) + const primaryMaxX = Math.min(retained.maxX, positiveRetained.maxX) + if (primaryMaxX > primaryMinX + 1e-6) { + addBentStrip({ + name: `lean-to-purlin-${index}`, + centerX: (primaryMinX + primaryMaxX) / 2, + totalWidth: primaryMaxX - primaryMinX, + height: node.purlinHeight, + depth: node.purlinWidth, + localZ: z, + y, + rotationX: primarySlope, + role: 'joinery', + material: framingMaterial, + slotId: 'framing', + }) + } + if (dualSlope) { + const negativeRetained = retainedCanopyWidthAtZ('negative', -z) + const oppositeMinX = Math.max(retained.minX, negativeRetained.minX) + const oppositeMaxX = Math.min(retained.maxX, negativeRetained.maxX) + if (oppositeMaxX > oppositeMinX + 1e-6) { + addBentStrip({ + name: `lean-to-opposite-purlin-${index}`, + centerX: (oppositeMinX + oppositeMaxX) / 2, + totalWidth: oppositeMaxX - oppositeMinX, + height: node.purlinHeight, + depth: node.purlinWidth, + localZ: -z, + y, + rotationX: oppositeSlope, + role: 'joinery', + material: framingMaterial, + slotId: 'framing', + }) + } + } } } 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/joint-framing.test.ts b/packages/nodes/src/lean-to-extension/joint-framing.test.ts new file mode 100644 index 0000000000..f4c45d81b8 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/joint-framing.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LevelNode } from '@pascal-app/core' +import { generateRoofSegmentGeometry } from '@pascal-app/viewer' +import { type BoxGeometry, Matrix4, Mesh, Raycaster, Vector3 } from 'three' +import { createLeanToAssembly } from './assembly' +import { buildLeanToExtensionGeometry } from './geometry' +import { resolveLeanToFreestandingRunPlacement } from './placement' + +function exposedCornerFraming(canopyForm: 'mono' | 'gable' | 'butterfly', turnZ: -4 | 4): string[] { + const level = LevelNode.parse({ id: `level_${canopyForm}_${turnZ}`, level: 0 }) + const first = resolveLeanToFreestandingRunPlacement(level.id, [0, 0], [4, 0], false, canopyForm)! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + [4, 0], + [4, turnZ], + false, + canopyForm, + )! + const runs = [first, second] + const nodes = Object.fromEntries([level, ...runs].map((node) => [node.id, node])) as Record< + string, + AnyNode + > + const assemblies = runs.map((run) => createLeanToAssembly(run, undefined, nodes)) + const roofMeshes = assemblies.flatMap((assembly, index) => { + const run = runs[index]! + return [assembly.segment, assembly.oppositeSegment] + .filter((segment) => segment !== undefined) + .map((segment) => { + const matrix = new Matrix4() + .makeTranslation(...run.position) + .multiply(new Matrix4().makeRotationY(run.rotation[1])) + .multiply(new Matrix4().makeTranslation(...segment.position)) + .multiply(new Matrix4().makeRotationY(segment.rotation)) + return new Mesh(generateRoofSegmentGeometry(segment).applyMatrix4(matrix)) + }) + }) + const raycaster = new Raycaster() + raycaster.ray.direction.set(0, -1, 0) + const exposed: string[] = [] + + for (const [index, assembly] of assemblies.entries()) { + const run = runs[index]! + const framing = buildLeanToExtensionGeometry(assembly.extension, {} as never) + framing.applyMatrix4( + new Matrix4() + .makeTranslation(...run.position) + .multiply(new Matrix4().makeRotationY(run.rotation[1])), + ) + framing.updateMatrixWorld(true) + + for (const member of framing.children.filter( + (child): child is Mesh => + /^lean-to-(?:opposite-)?rafter-\d+$/.test(child.name) || /corner-rafter$/.test(child.name), + )) { + const { depth } = member.geometry.parameters as { depth: number } + for (const z of [-depth * 0.35, 0, depth * 0.35]) { + const point = member.localToWorld(new Vector3(0, 0, z)) + raycaster.ray.origin.set(point.x, 10, point.z) + const coverY = Math.max( + ...roofMeshes.flatMap((roof) => + raycaster.intersectObject(roof, false).map((hit) => hit.point.y), + ), + ) + if (!Number.isFinite(coverY) || coverY <= point.y) { + exposed.push(`${index}:${member.name}:${point.x.toFixed(3)}:${point.z.toFixed(3)}`) + } + } + } + } + + for (const roof of roofMeshes) roof.geometry.dispose() + return exposed +} + +describe('freestanding canopy joint framing', () => { + test('keeps mono corner framing below an internal turn', () => { + expect(exposedCornerFraming('mono', -4)).toEqual([]) + }) + + test.each([ + 'gable', + 'butterfly', + ] as const)('keeps both %s roof-half framings below either internal turn', (canopyForm) => { + expect(exposedCornerFraming(canopyForm, -4)).toEqual([]) + expect(exposedCornerFraming(canopyForm, 4)).toEqual([]) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/layout.test.ts b/packages/nodes/src/lean-to-extension/layout.test.ts index 9530c2979a..759e0c7dc7 100644 --- a/packages/nodes/src/lean-to-extension/layout.test.ts +++ b/packages/nodes/src/lean-to-extension/layout.test.ts @@ -12,7 +12,10 @@ import { resolveLeanToEdgeSnapTargets, resolveLeanToLayout, resolveLeanToMoveCenterX, + resolveLeanToMoveProposal, resolveLeanToParentPose, + resolveLeanToPlanCenter, + resolveLeanToSpanResizeProposal, resolveLeanToWallPlacement, resolveLeanToWallSurfaceHit, } from './layout' @@ -54,6 +57,40 @@ describe('lean-to extension layout', () => { }) expect(resolveLeanToLayout(node).postXs).toHaveLength(5) }) + + test('resolves a gable canopy as two symmetric roof planes', () => { + const node = LeanToExtensionNode.parse({ + canopyForm: 'gable', + hostKind: 'freestanding', + projection: 3, + lowOverhang: 0.25, + highOverhang: 0.4, + }) + const layout = resolveLeanToLayout(node) + + expect(layout.canopyForm).toBe('gable') + expect(layout.roofRun).toBeCloseTo(3.25) + expect(layout.oppositeBeamZ).toBeCloseTo(-layout.beamZ) + expect(layout.roofCenterZ).toBeCloseTo(1.625) + }) + + test('resolves a butterfly canopy with a low central valley and high outer eaves', () => { + const node = LeanToExtensionNode.parse({ + canopyForm: 'butterfly', + hostKind: 'freestanding', + projection: 3, + lowOverhang: 0.25, + highEdgeHeight: 3.2, + pitch: 10, + }) + const layout = resolveLeanToLayout(node) + + expect(layout.roofRun).toBeCloseTo(3.25) + expect(layout.roofCenterY).toBeGreaterThan(layout.lowEdgeHeight) + expect(layout.roofCenterY).toBeLessThan(layout.highEdgeHeight) + expect(layout.oppositeBeamZ).toBeCloseTo(-layout.beamZ) + expect(resolveLeanToPlanCenter(node)[1]).toBe(0) + }) }) describe('lean-to wall placement', () => { @@ -201,7 +238,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 +254,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..286a6b581a 100644 --- a/packages/nodes/src/lean-to-extension/layout.ts +++ b/packages/nodes/src/lean-to-extension/layout.ts @@ -10,15 +10,23 @@ 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 function isDualSlopeLeanToCanopy(form: LeanToExtensionNode['canopyForm']): boolean { + return form === 'gable' || form === 'butterfly' +} + export type LeanToLayout = { + canopyForm: LeanToExtensionNode['canopyForm'] span: number projection: number roofRun: number @@ -38,6 +46,7 @@ export type LeanToLayout = { beamSpan: number beamCenterY: number beamZ: number + oppositeBeamZ: number postHeight: number postXs: number[] rafterXs: number[] @@ -59,27 +68,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, } } @@ -97,11 +100,14 @@ export function applyLeanToCurveProjectionLimit(node: LeanToExtensionNode): Lean } export function resolveLeanToLayout(node: LeanToExtensionNode): LeanToLayout { + const canopyForm = node.hostKind === 'freestanding' ? node.canopyForm : 'mono' + const butterfly = canopyForm === 'butterfly' + const dualSlope = isDualSlopeLeanToCanopy(canopyForm) const span = Math.max(0.5, node.span) const projection = Math.max(0.5, node.projection) - const highOverhang = Math.max(0, node.highOverhang) + const highOverhang = dualSlope ? 0 : Math.max(0, node.highOverhang) const lowOverhang = Math.max(0, node.lowOverhang) - const roofRun = highOverhang + projection + lowOverhang + const roofRun = dualSlope ? projection + lowOverhang : highOverhang + projection + lowOverhang const roofWidth = span + Math.max(0, node.leftOverhang) + Math.max(0, node.rightOverhang) const roofCenterX = (Math.max(0, node.rightOverhang) - Math.max(0, node.leftOverhang)) / 2 const requestedPitch = (Math.max(1, Math.min(45, node.pitch)) * Math.PI) / 180 @@ -114,9 +120,13 @@ export function resolveLeanToLayout(node: LeanToExtensionNode): LeanToLayout { const pitchRadians = Math.min(requestedPitch, maximumPitch) const effectivePitchDegrees = (pitchRadians * 180) / Math.PI const lowEdgeHeight = node.highEdgeHeight - projection * Math.tan(pitchRadians) - const eaveEdgeHeight = node.highEdgeHeight - (projection + lowOverhang) * Math.tan(pitchRadians) + const eaveEdgeHeight = butterfly + ? lowEdgeHeight + : node.highEdgeHeight - (projection + lowOverhang) * Math.tan(pitchRadians) const roofCenterZ = (projection + lowOverhang - highOverhang) / 2 - const roofCenterY = node.highEdgeHeight - roofCenterZ * Math.tan(pitchRadians) + const roofCenterY = butterfly + ? lowEdgeHeight + roofCenterZ * Math.tan(pitchRadians) + : node.highEdgeHeight - roofCenterZ * Math.tan(pitchRadians) const effectiveRoofBuildUp = node.roofThickness / Math.max(0.1, Math.cos(pitchRadians)) + (node.shingleThickness ?? 0.025) * Math.cos(pitchRadians) @@ -128,30 +138,46 @@ export function resolveLeanToLayout(node: LeanToExtensionNode): LeanToLayout { ) const rafterCenterZ = rafterRun / 2 const rafterCenterY = - node.highEdgeHeight - - rafterCenterZ * Math.tan(pitchRadians) - + (butterfly + ? lowEdgeHeight + rafterCenterZ * Math.tan(pitchRadians) + : node.highEdgeHeight - rafterCenterZ * Math.tan(pitchRadians)) - effectiveRoofBuildUp - node.rafterHeight / 2 const beamZ = Math.max(0, projection - node.lowBeamInset) const beamTop = - node.highEdgeHeight - beamZ * Math.tan(pitchRadians) - effectiveRoofBuildUp - node.rafterHeight + (butterfly + ? lowEdgeHeight + beamZ * Math.tan(pitchRadians) + : node.highEdgeHeight - beamZ * Math.tan(pitchRadians)) - + effectiveRoofBuildUp - + node.rafterHeight const beamCenterY = beamTop - node.beamHeight / 2 const postHeight = Math.max(MIN_LEAN_TO_POST_HEIGHT, beamCenterY - node.beamHeight / 2) const usablePostSpan = Math.max(0.1, span - 2 * Math.max(0, node.postInset)) + const 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 { + canopyForm, span, projection, roofRun, @@ -171,6 +197,7 @@ export function resolveLeanToLayout(node: LeanToExtensionNode): LeanToLayout { beamSpan, beamCenterY, beamZ, + oppositeBeamZ: -beamZ, postHeight, postXs, rafterXs, @@ -179,6 +206,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, isDualSlopeLeanToCanopy(layout.canopyForm) ? 0 : 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 +247,165 @@ 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 +415,14 @@ 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 +431,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 +442,54 @@ 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 +497,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 +571,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 +593,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..9296a3c709 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/managed-preview.ts @@ -0,0 +1,142 @@ +import type { AnyNode, AnyNodeId, LeanToExtensionNode, SceneApi } from '@pascal-app/core' +import { + isManagedLeanToNode, + isManagedLeanToPost, + leanToCanopyCornerPostLayoutPatch, + leanToCornerPostIndex, + leanToCornerPostLayoutPatch, + leanToDownspoutLayoutPatch, + leanToGutterLayoutPatch, + leanToPostLayoutPatch, + leanToRoofSegmentLayoutPatch, + managedLeanToPostIndex, + managedLeanToPostSide, + managedLeanToRoofPlane, + resolveLeanToPostBaseY, + resolveLeanToPostBaseYAtLocalPosition, + resolveLeanToPostGutterSetback, +} from './assembly' +import { resolveFreestandingCanopyJoints } from './canopy-joint' +import { resolveLeanToCornerJoints } from './corner-joint' +import { isDualSlopeLeanToCanopy } from './layout' + +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 + const cornerJoints = resolveLeanToCornerJoints( + next, + wall?.type === 'wall' ? wall : undefined, + nodes, + ) + const canopyJoints = resolveFreestandingCanopyJoints(next, nodes) + 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 cornerSide = + index === leanToCornerPostIndex('left') + ? 'left' + : index === leanToCornerPostIndex('right') + ? 'right' + : null + if (cornerSide) { + const gutterSetback = resolveLeanToPostGutterSetback(next, child) + const cornerJoint = cornerJoints[cornerSide] + const canopyJoint = canopyJoints[cornerSide] + const ungroundedPatch = cornerJoint + ? leanToCornerPostLayoutPatch(next, cornerJoint, 0, gutterSetback) + : canopyJoint + ? leanToCanopyCornerPostLayoutPatch(next, canopyJoint, side, 0, gutterSetback) + : null + if (!ungroundedPatch) continue + const baseY = resolveLeanToPostBaseYAtLocalPosition( + next, + wall?.type === 'wall' ? wall : undefined, + nodes, + ungroundedPatch.position, + ) + entries.push([ + child.id as AnyNodeId, + (cornerJoint + ? leanToCornerPostLayoutPatch(next, cornerJoint, baseY, gutterSetback) + : leanToCanopyCornerPostLayoutPatch( + next, + canopyJoint!, + side, + baseY, + gutterSetback, + )) as Partial, + ]) + continue + } + const baseY = + wall?.type === 'wall' ? resolveLeanToPostBaseY(next, wall, nodes, index, side) : 0 + const gutterSetback = + side === 'low' || (side === 'high' && isDualSlopeLeanToCanopy(next.canopyForm)) + ? 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 segments = child.children + .map((id) => nodes[id as AnyNodeId]) + .filter( + (candidate): candidate is Extract => + candidate?.type === 'roof-segment' && + isManagedLeanToNode(candidate, next.id, 'roof-segment'), + ) + const segment = segments.find((candidate) => managedLeanToRoofPlane(candidate) === 'primary') + for (const candidate of segments) { + const plane = managedLeanToRoofPlane(candidate) + const candidatePatch = leanToRoofSegmentLayoutPatch(next, nodes, plane) + entries.push([candidate.id as AnyNodeId, candidatePatch as Partial]) + } + if (!segment) continue + + const nextSegment = { + ...segment, + ...leanToRoofSegmentLayoutPatch(next, nodes, 'primary'), + } + 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/mono-j-rendering.test.ts b/packages/nodes/src/lean-to-extension/mono-j-rendering.test.ts new file mode 100644 index 0000000000..1deb567c91 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/mono-j-rendering.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LevelNode } from '@pascal-app/core' +import { generateRoofSegmentGeometry } from '@pascal-app/viewer' +import { Vector3 } from 'three' +import { createLeanToAssembly } from './assembly' +import { resolveLeanToFreestandingRunPlacement } from './placement' + +describe('continuous mono J rendering', () => { + test('closes internal roof-plane steps without exposing fascia', () => { + const level = LevelNode.parse({ id: 'level_mono_render_j', level: 0 }) + const points = [ + [-6, 2.5], + [0, -3.5], + [4.5, 1.5], + [2, 4], + ] as const + const runs = points + .slice(0, -1) + .map( + (start, index) => + resolveLeanToFreestandingRunPlacement( + level.id, + start, + points[index + 1]!, + false, + 'mono', + )!, + ) + const sourceNodes = Object.fromEntries( + [level, ...runs].map((node) => [node.id, node]), + ) as Record + const assemblies = runs.map((run) => createLeanToAssembly(run, undefined, sourceNodes)) + const renderNodes = Object.fromEntries( + [level, ...runs, ...assemblies.flatMap((assembly) => [assembly.roof, assembly.segment])].map( + (node) => [node.id, node], + ), + ) as Record + + let isolatedWhiteVerticalArea = 0 + let joinedWhiteVerticalArea = 0 + let joinedRoofFinishMaxSamePointVerticalSpan = 0 + for (const { segment } of assemblies) { + const isolated = verticalAreas(generateRoofSegmentGeometry(segment)) + const joined = verticalAreas(generateRoofSegmentGeometry(segment, renderNodes)) + isolatedWhiteVerticalArea += isolated.white + joinedWhiteVerticalArea += joined.white + joinedRoofFinishMaxSamePointVerticalSpan = Math.max( + joinedRoofFinishMaxSamePointVerticalSpan, + joined.roofFinishMaxSamePointVerticalSpan, + ) + } + + expect(joinedWhiteVerticalArea).toBeLessThan(isolatedWhiteVerticalArea - 0.01) + expect(joinedRoofFinishMaxSamePointVerticalSpan).toBeGreaterThan(0.4) + }) +}) + +function verticalAreas(geometry: ReturnType) { + const position = geometry.getAttribute('position') + const index = geometry.getIndex()! + const a = new Vector3() + const b = new Vector3() + const c = new Vector3() + const ab = new Vector3() + const ac = new Vector3() + const normal = new Vector3() + let white = 0 + let roofFinish = 0 + let roofFinishMaxSamePointVerticalSpan = 0 + for (const group of geometry.groups) { + if (group.materialIndex !== 0 && group.materialIndex !== 3) continue + for (let offset = group.start; offset < group.start + group.count; offset += 3) { + a.fromBufferAttribute(position, index.getX(offset)) + b.fromBufferAttribute(position, index.getX(offset + 1)) + c.fromBufferAttribute(position, index.getX(offset + 2)) + normal.crossVectors(ab.subVectors(b, a), ac.subVectors(c, a)) + const area = normal.length() / 2 + normal.normalize() + if (Math.abs(normal.y) > 0.05) continue + if (group.materialIndex === 0) white += area + else { + roofFinish += area + for (const [first, second] of [ + [a, b], + [b, c], + [c, a], + ] as const) { + if (Math.hypot(first.x - second.x, first.z - second.z) > 1e-5) continue + roofFinishMaxSamePointVerticalSpan = Math.max( + roofFinishMaxSamePointVerticalSpan, + Math.abs(first.y - second.y), + ) + } + } + } + } + geometry.dispose() + return { roofFinish, roofFinishMaxSamePointVerticalSpan, white } +} 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/multi-joint-mono.test.ts b/packages/nodes/src/lean-to-extension/multi-joint-mono.test.ts new file mode 100644 index 0000000000..eed003bda9 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/multi-joint-mono.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LevelNode } from '@pascal-app/core' +import { createLeanToAssembly } from './assembly' +import { resolveLeanToFreestandingRunPlacement } from './placement' + +type Point = readonly [number, number] + +function polygonArea(points: readonly Point[]): number { + let area = 0 + for (let index = 0; index < points.length; index++) { + const current = points[index]! + const next = points[(index + 1) % points.length]! + area += current[0] * next[1] - next[0] * current[1] + } + return Math.abs(area / 2) +} + +function orientation(a: Point, b: Point, c: Point): number { + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) +} + +function hasSelfIntersection(polygon: readonly Point[]): boolean { + for (let first = 0; first < polygon.length; first++) { + for (let second = first + 1; second < polygon.length; second++) { + const adjacent = second === first + 1 || (first === 0 && second === polygon.length - 1) + if (adjacent) continue + const a = polygon[first]! + const b = polygon[second]! + if (Math.hypot(a[0] - b[0], a[1] - b[1]) <= 1e-8) return true + } + } + for (let first = 0; first < polygon.length; first++) { + const firstNext = (first + 1) % polygon.length + for (let second = first + 1; second < polygon.length; second++) { + const secondNext = (second + 1) % polygon.length + if ( + first === second || + firstNext === second || + secondNext === first || + (first === 0 && secondNext === 0) + ) { + continue + } + const a = polygon[first]! + const b = polygon[firstNext]! + const c = polygon[second]! + const d = polygon[secondNext]! + const firstSide = orientation(a, b, c) + const secondSide = orientation(a, b, d) + const thirdSide = orientation(c, d, a) + const fourthSide = orientation(c, d, b) + if (firstSide * secondSide < -1e-10 && thirdSide * fourthSide < -1e-10) return true + } + } + return false +} + +describe('multi-joint mono canopy', () => { + test('keeps both branches simple and preserves their area at the failing concave turn', () => { + const level = LevelNode.parse({ id: 'level_mono_z_chain', level: 0 }) + const runs = [ + resolveLeanToFreestandingRunPlacement(level.id, [2, 12], [7, 7], false, 'mono')!, + resolveLeanToFreestandingRunPlacement(level.id, [7, 7], [15.5, 7.5], false, 'mono')!, + ] + const nodes = Object.fromEntries([level, ...runs].map((node) => [node.id, node])) as Record< + string, + AnyNode + > + const footprintsByRun = runs.map( + (run) => createLeanToAssembly(run, undefined, nodes).segment.shedFootprintPieces ?? [], + ) + + expect(footprintsByRun.every((footprints) => footprints.length > 0)).toBe(true) + expect(footprintsByRun.flat().some(hasSelfIntersection)).toBe(false) + expect( + footprintsByRun[0]!.reduce((sum, footprint) => sum + polygonArea(footprint), 0), + ).toBeCloseTo(18.303629451612156) + expect( + footprintsByRun[1]!.reduce((sum, footprint) => sum + polygonArea(footprint), 0), + ).toBeCloseTo(22.29958447881066) + }) + + test('keeps every roof footprint simple across both joints in the reported layout', () => { + const level = LevelNode.parse({ id: 'level_mono_reported_layout', level: 0 }) + const runs = [ + resolveLeanToFreestandingRunPlacement(level.id, [-7, 11.5], [2, 12], false, 'mono')!, + resolveLeanToFreestandingRunPlacement(level.id, [2, 12], [7, 7], false, 'mono')!, + resolveLeanToFreestandingRunPlacement(level.id, [7, 7], [15.5, 7.5], false, 'mono')!, + ] + const nodes = Object.fromEntries([level, ...runs].map((node) => [node.id, node])) as Record< + string, + AnyNode + > + const footprints = runs.flatMap( + (run) => createLeanToAssembly(run, undefined, nodes).segment.shedFootprintPieces ?? [], + ) + + expect(footprints.length).toBeGreaterThanOrEqual(runs.length) + expect(footprints.some(hasSelfIntersection)).toBe(false) + }) + + test('keeps every roof partition simple when the first run forms the top of a J', () => { + const level = LevelNode.parse({ id: 'level_mono_browser_top_first_j', level: 0 }) + const points: Point[] = [ + [-6, 2.5], + [0, -3.5], + [4.5, 1.5], + [2, 4], + ] + const runs = points + .slice(0, -1) + .map((start, index) => + resolveLeanToFreestandingRunPlacement(level.id, start, points[index + 1]!, false, 'mono'), + ) + const nodes = Object.fromEntries([level, ...runs].map((node) => [node!.id, node])) as Record< + string, + AnyNode + > + const footprintsByRun = runs.map( + (run) => createLeanToAssembly(run!, undefined, nodes).segment.shedFootprintPieces ?? [], + ) + + expect(footprintsByRun.every((footprints) => footprints.length > 0)).toBe(true) + expect(footprintsByRun.flat().some(hasSelfIntersection)).toBe(false) + expect( + footprintsByRun.map((footprints) => + footprints.reduce((sum, footprint) => sum + polygonArea(footprint), 0), + ), + ).toEqual([ + expect.closeTo(20.481444541008713), + expect.closeTo(11.77982856764142), + expect.closeTo(6.782585040996875), + ]) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/parametrics.ts b/packages/nodes/src/lean-to-extension/parametrics.ts index b8061445af..ada799159b 100644 --- a/packages/nodes/src/lean-to-extension/parametrics.ts +++ b/packages/nodes/src/lean-to-extension/parametrics.ts @@ -62,6 +62,9 @@ export function deriveLeanToResizePatch( export const leanToExtensionParametrics: ParametricDescriptor = { derive: (next, patch, previous = next) => { return { + ...(patch.canopyForm === 'gable' || patch.canopyForm === 'butterfly' + ? { highSideMode: 'independent-high-beam' as const, autoSpan: false } + : {}), ...(patch.connectionMode === 'manual' ? { hostRoofId: undefined, @@ -75,6 +78,12 @@ export const leanToExtensionParametrics: ParametricDescriptor node.hostKind === 'freestanding', + }, + { + key: 'autoSpan', + label: 'Match host width', + kind: 'boolean', + visibleIf: (node) => node.hostKind !== 'freestanding', + }, { key: 'span', label: 'Width', @@ -103,7 +125,7 @@ export const leanToExtensionParametrics: ParametricDescriptor node.connectionMode === 'manual' || !node.hostRoofSegmentId, }, - { key: 'pitch', label: 'Slope', kind: 'number', unit: '°', min: 1, max: 45, step: 1 }, + { + key: 'pitch', + label: 'Slope', + kind: 'number', + unit: '°', + min: 1, + max: 45, + step: 1, + }, ], }, { @@ -123,12 +153,14 @@ 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', @@ -224,7 +256,11 @@ export const leanToExtensionParametrics: ParametricDescriptor node.framingStrategy === 'purlins' || node.framingStrategy === 'covering-specific', }, - { key: 'postBracing', label: 'Post bracing', kind: 'enum', options: ['none', 'knee'] }, + { + key: 'postBracing', + label: 'Post bracing', + kind: 'enum', + options: ['none', 'knee'], + }, { key: 'footingStyle', label: 'Footings', diff --git a/packages/nodes/src/lean-to-extension/placement-scope.test.ts b/packages/nodes/src/lean-to-extension/placement-scope.test.ts new file mode 100644 index 0000000000..ffa8d5c7d2 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement-scope.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + LevelNode, + RoofNode, + RoofSegmentNode, + WallNode, +} from '@pascal-app/core' +import { isLeanToHostOnLevel } from './placement-scope' + +describe('lean-to placement scope', () => { + 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..0854534e8a --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement.test.ts @@ -0,0 +1,528 @@ +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, + nextLeanToCanopyForm, + nextLeanToPlacementRotation, + reconcileLeanToSlabEdgePlacement, + resolveLeanToCommitTarget, + resolveLeanToFreestandingPlacement, + resolveLeanToFreestandingRunEndpointSnap, + resolveLeanToFreestandingRunPlacement, + resolveLeanToFreestandingRunTarget, + 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('cycles freestanding placement through mono, gable, and butterfly forms', () => { + expect(nextLeanToCanopyForm('mono', 'f')).toBe('gable') + expect(nextLeanToCanopyForm('gable', 'F')).toBe('butterfly') + expect(nextLeanToCanopyForm('butterfly', 'f')).toBe('mono') + expect(nextLeanToCanopyForm('gable', 'r')).toBe('gable') + + const target = resolveLeanToPlanPlacement({ + activeLevelId: 'level_ground', + freestandingPoint: [4, 6], + freestandingCanopyForm: 'gable', + nodes: {}, + point: [4, 6], + }) + expect(target.node).toMatchObject({ + canopyForm: 'gable', + hostKind: 'freestanding', + position: [4, 0, 6], + }) + + expect( + resolveLeanToFreestandingPlacement('level_ground', [4, 6], 0, 'butterfly'), + ).toMatchObject({ + name: 'Freestanding Butterfly Canopy', + canopyForm: 'butterfly', + position: [4, 0, 6], + }) + }) + + test('resolves a continuous freestanding run from its clicked endpoints', () => { + const node = resolveLeanToFreestandingRunPlacement('level_ground', [1, 2], [5, 5]) + + expect(node).not.toBeNull() + expect(node?.canopyForm).toBe('mono') + expect(node?.span).toBeCloseTo(5) + expect(node?.position).toEqual([3, 0, 3.5]) + expect(node?.rotation[1]).toBeCloseTo(-Math.atan2(3, 4)) + }) + + test('flips the projection side without changing the continuous run endpoints', () => { + const normal = resolveLeanToFreestandingRunPlacement('level_ground', [0, 0], [4, 0]) + const flipped = resolveLeanToFreestandingRunPlacement('level_ground', [0, 0], [4, 0], true) + + expect(flipped?.span).toBe(normal?.span) + expect(flipped?.position).toEqual(normal?.position) + expect(Math.abs((flipped?.rotation[1] ?? 0) - (normal?.rotation[1] ?? 0))).toBeCloseTo(Math.PI) + }) + + test('rejects a continuous run shorter than the canopy minimum span', () => { + expect(resolveLeanToFreestandingRunPlacement('level_ground', [0, 0], [0.2, 0])).toBeNull() + }) + + test.each([ + 'gable', + 'butterfly', + ] as const)('keeps the %s canopy form throughout a continuous run', (canopyForm) => { + const node = resolveLeanToFreestandingRunPlacement( + 'level_ground', + [0, 0], + [4, 0], + false, + canopyForm, + ) + const target = resolveLeanToFreestandingRunTarget({ + activeLevelId: 'level_ground', + canopyForm, + start: [4, 0], + end: [4, 4], + nodes: node ? { [node.id]: node } : {}, + }) + + expect(node?.canopyForm).toBe(canopyForm) + expect(target?.node.canopyForm).toBe(canopyForm) + }) + + test.each([ + 'mono', + 'gable', + 'butterfly', + ] as const)('magnetically closes a continuous %s loop at an exposed endpoint', (canopyForm) => { + const first = resolveLeanToFreestandingRunPlacement( + 'level_ground', + [0, 0], + [4, 0], + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + 'level_ground', + [4, 0], + [4, 4], + false, + canopyForm, + )! + const third = resolveLeanToFreestandingRunPlacement( + 'level_ground', + [4, 4], + [0, 4], + false, + canopyForm, + )! + const snap = resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId: 'level_ground', + canopyForm, + maxDistance: 0.5, + nodes: Object.fromEntries([first, second, third].map((node) => [node.id, node])), + proposedEnd: [0.18, 0.12], + start: [0, 4], + }) + + expect(snap).toMatchObject({ + nodeId: first.id, + point: [0, 0], + side: 'left', + }) + }) + + test('does not magnetize to an occupied, incompatible, or out-of-range endpoint', () => { + const occupied = { + ...resolveLeanToFreestandingRunPlacement('level_ground', [0, 0], [4, 0])!, + leftEndCondition: 'joined' as const, + } + const gable = resolveLeanToFreestandingRunPlacement( + 'level_ground', + [8, 0], + [12, 0], + false, + 'gable', + )! + const nodes = { [occupied.id]: occupied, [gable.id]: gable } + + expect( + resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId: 'level_ground', + nodes, + proposedEnd: [0.1, 0.1], + start: [0, 4], + }), + ).toBeNull() + expect( + resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId: 'level_ground', + nodes, + proposedEnd: [8.1, 0.1], + start: [8, 4], + }), + ).toBeNull() + expect( + resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId: 'level_ground', + maxDistance: 0.05, + nodes: { [occupied.id]: { ...occupied, leftEndCondition: 'open' } }, + proposedEnd: [0.1, 0.1], + start: [0, 4], + }), + ).toBeNull() + }) + + 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..a51af2ca76 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement.ts @@ -0,0 +1,503 @@ +import { + type AnyNode, + type AnyNodeId, + getLevelElevations, + LeanToExtensionNode, + type SlabNode, + type WallNode, +} from '@pascal-app/core' +import { findClosestWallAttachmentInPlan } from '../shared/wall-attach-target' +import { + canopyCornerJointMetadata, + FREESTANDING_CANOPY_JOINTS_KEY, + resolveFreestandingCanopyJoints, +} from './canopy-joint' +import { + LEAN_TO_CORNER_JOINTS_KEY, + type LeanToCornerSide, + leanToCornerJointMetadata, + resolveLeanToCornerJoints, +} from './corner-joint' +import { + isDualSlopeLeanToCanopy, + 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 { + const joints = resolveLeanToCornerJoints(node, wall, nodes) + const canopyJoints = resolveFreestandingCanopyJoints(node, nodes) + if (Object.keys(joints).length === 0 && Object.keys(canopyJoints).length === 0) return node + return { + ...node, + leftEndCondition: joints.left || canopyJoints.left ? 'joined' : node.leftEndCondition, + rightEndCondition: joints.right || canopyJoints.right ? 'joined' : node.rightEndCondition, + metadata: { + ...(node.metadata && typeof node.metadata === 'object' ? node.metadata : {}), + [LEAN_TO_CORNER_JOINTS_KEY]: leanToCornerJointMetadata(joints), + [FREESTANDING_CANOPY_JOINTS_KEY]: canopyCornerJointMetadata(canopyJoints), + }, + } +} + +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 const LEAN_TO_RUN_CONNECT_SNAP_RADIUS = 0.05 +export const LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS = 0.5 + +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, + canopyForm: LeanToExtensionNode['canopyForm'] = 'mono', +): LeanToExtensionNode { + const parsed = LeanToExtensionNode.parse({ + name: + canopyForm === 'gable' + ? 'Freestanding Gable Canopy' + : canopyForm === 'butterfly' + ? 'Freestanding Butterfly Canopy' + : 'Freestanding Lean-to Canopy', + parentId: levelId, + canopyForm, + 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 resolveLeanToFreestandingRunPlacement( + levelId: string, + start: readonly [number, number], + end: readonly [number, number], + flipProjection = false, + canopyForm: LeanToExtensionNode['canopyForm'] = 'mono', +): LeanToExtensionNode | null { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const span = Math.hypot(dx, dz) + if (span < 0.5) return null + const from = flipProjection ? end : start + const to = flipProjection ? start : end + const rotationY = Math.atan2(-(to[1] - from[1]), to[0] - from[0]) + const midpoint: [number, number] = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + const node = resolveLeanToFreestandingPlacement(levelId, midpoint, rotationY, canopyForm) + return { + ...node, + span, + position: [midpoint[0], node.position[1], midpoint[1]], + } +} + +export type LeanToFreestandingRunEndpointSnap = { + nodeId: string + point: [number, number] + side: LeanToCornerSide +} + +function freestandingRunEndpoint( + node: LeanToExtensionNode, + side: LeanToCornerSide, +): [number, number] { + const sign = side === 'left' ? -1 : 1 + const cos = Math.cos(node.rotation[1]) + const sin = Math.sin(node.rotation[1]) + return [ + node.position[0] + sign * cos * (node.span / 2), + node.position[2] - sign * sin * (node.span / 2), + ] +} + +export function resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId, + canopyForm = 'mono', + flipProjection = false, + maxDistance = LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS, + nodes, + proposedEnd, + start, +}: { + activeLevelId: AnyNodeId + canopyForm?: LeanToExtensionNode['canopyForm'] + flipProjection?: boolean + maxDistance?: number + nodes: Record + proposedEnd: readonly [number, number] + start: readonly [number, number] +}): LeanToFreestandingRunEndpointSnap | null { + let best: (LeanToFreestandingRunEndpointSnap & { distance: number }) | null = null + for (const candidate of Object.values(nodes)) { + if ( + candidate.type !== 'lean-to-extension' || + candidate.parentId !== activeLevelId || + candidate.hostKind !== 'freestanding' || + candidate.canopyForm !== canopyForm || + !candidate.autoMiterCorners + ) { + continue + } + const candidateEndpoints = { + left: freestandingRunEndpoint(candidate, 'left'), + right: freestandingRunEndpoint(candidate, 'right'), + } + if ( + Object.values(candidateEndpoints).some( + (point) => Math.hypot(point[0] - start[0], point[1] - start[1]) <= 1e-4, + ) + ) { + continue + } + for (const side of ['left', 'right'] as const) { + if (candidate[side === 'left' ? 'leftEndCondition' : 'rightEndCondition'] === 'joined') { + continue + } + const point = candidateEndpoints[side] + const distance = Math.hypot(point[0] - proposedEnd[0], point[1] - proposedEnd[1]) + if (distance > maxDistance || (best && distance >= best.distance)) continue + const proposed = resolveLeanToFreestandingRunPlacement( + activeLevelId, + start, + point, + flipProjection, + canopyForm, + ) + if (!proposed) continue + const ownSide = flipProjection ? 'left' : 'right' + const joint = isDualSlopeLeanToCanopy(canopyForm) + ? resolveFreestandingCanopyJoints(proposed, nodes)[ownSide] + : resolveLeanToCornerJoints(proposed, undefined, nodes)[ownSide] + if (joint?.neighborId !== candidate.id || joint.neighborSide !== side) continue + best = { distance, nodeId: candidate.id, point, side } + } + } + if (!best) return null + return { nodeId: best.nodeId, point: best.point, side: best.side } +} + +export function resolveLeanToFreestandingRunTarget({ + activeLevelId, + canopyForm = 'mono', + end, + flipProjection = false, + nodes, + start, +}: { + activeLevelId: AnyNodeId + canopyForm?: LeanToExtensionNode['canopyForm'] + end: readonly [number, number] + flipProjection?: boolean + nodes: Record + start: readonly [number, number] +}): LeanToPlanPlacementTarget | null { + const node = resolveLeanToFreestandingRunPlacement( + activeLevelId, + start, + end, + flipProjection, + canopyForm, + ) + if (!node) return null + return { + node: resolveLeanToPreviewNode(node, undefined, nodes), + valid: true, + } +} + +export function resolveLeanToPlanPlacement({ + activeLevelId, + freestandingPoint, + freestandingRotationY = 0, + freestandingCanopyForm = 'mono', + nodes, + point, +}: { + activeLevelId: AnyNodeId + freestandingPoint: readonly [number, number] + freestandingRotationY?: number + freestandingCanopyForm?: LeanToExtensionNode['canopyForm'] + 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, + freestandingCanopyForm, + ), + valid: true, + } +} + +export function nextLeanToCanopyForm( + current: LeanToExtensionNode['canopyForm'], + key: string, +): LeanToExtensionNode['canopyForm'] { + if (key !== 'f' && key !== 'F') return current + return current === 'mono' ? 'gable' : current === 'gable' ? 'butterfly' : 'mono' +} + +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..6a516d96de 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, @@ -13,8 +14,9 @@ import { buildGutterGeometry } from '../gutter/geometry' import { bendLocalPoint } from './arc' import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' import { resolveLeanToCornerJoints } from './corner-joint' -import { leanToWallLocalPose, resolveLeanToWallPlacement } from './layout' -import { applyLeanToWallAutoSpan } from './roof-attachment' +import { leanToWallLocalPose, resolveLeanToLayout, resolveLeanToWallPlacement } from './layout' +import { resolveLeanToFreestandingRunPlacement } from './placement' +import { applyLeanToWallAutoSpan, applyLeanToWallCornerSpan } from './roof-attachment' function cornerFixture(reverseWalls = false, sideOverhang = 0) { const wallA = WallNode.parse({ @@ -147,6 +149,17 @@ function segmentWorldMatrix( .multiply(new THREE.Matrix4().makeRotationY(segment.rotation)) } +function freestandingSegmentWorldMatrix( + leanTo: ReturnType, + segment: ReturnType['segment'], +) { + return new THREE.Matrix4() + .makeTranslation(...leanTo.position) + .multiply(new THREE.Matrix4().makeRotationY(leanTo.rotation[1])) + .multiply(new THREE.Matrix4().makeTranslation(...segment.position)) + .multiply(new THREE.Matrix4().makeRotationY(segment.rotation)) +} + function cornerPlanPointToWorld( wall: ReturnType, leanTo: ReturnType, @@ -217,7 +230,7 @@ function getSegmentSlopeFrameForTest(segment: ReturnType, leanTo: ReturnType, @@ -344,6 +376,137 @@ function boundaryVerticesNear( } describe('lean-to corner joint', () => { + test('mitres two freestanding mono canopy runs that share a drafted endpoint', () => { + const first = resolveLeanToFreestandingRunPlacement('level_free_run', [0, 0], [4, 0])! + const second = resolveLeanToFreestandingRunPlacement('level_free_run', [4, 0], [4, 4])! + const nodes = { [first.id]: first, [second.id]: second } + + const firstJoint = resolveLeanToCornerJoints(first, undefined, nodes).right + const secondJoint = resolveLeanToCornerJoints(second, undefined, nodes).left + + expect(firstJoint?.neighborId).toBe(second.id) + expect(secondJoint?.neighborId).toBe(first.id) + expect(firstJoint?.seam).not.toBeNull() + expect(secondJoint?.seam).not.toBeNull() + expect(firstJoint?.sharedPostOwner).not.toBe(secondJoint?.sharedPostOwner) + }) + + test('emits valid roof outlines for both freestanding corner directions', () => { + for (const [turnZ, expectedKind] of [ + [-4, 'convex'], + [4, 'concave'], + ] as const) { + const first = resolveLeanToFreestandingRunPlacement('level_free_reference', [0, 0], [4, 0])! + const second = resolveLeanToFreestandingRunPlacement( + 'level_free_reference', + [4, 0], + [4, turnZ], + )! + const nodes = { [first.id]: first, [second.id]: second } + const joints = [ + resolveLeanToCornerJoints(first, undefined, nodes).right, + resolveLeanToCornerJoints(second, undefined, nodes).left, + ] + const assemblies = [ + createLeanToAssembly(first, undefined, nodes), + createLeanToAssembly(second, undefined, nodes), + ] + + expect(joints.map((joint) => joint?.kind)).toEqual([expectedKind, expectedKind]) + expect( + assemblies.every((assembly) => (assembly.segment.shedFootprintPieces?.length ?? 0) > 0), + ).toBe(true) + for (const [leanTo, assembly] of [ + [first, assemblies[0]], + [second, assemblies[1]], + ] as const) { + const halfWidth = assembly.segment.width / 2 + const outlyingPoints = assembly.segment + .shedFootprintPieces!.flat() + .filter(([x]) => Math.abs(x) > halfWidth + 1e-6) + expect(outlyingPoints).toEqual([]) + if (expectedKind === 'concave') { + expect(assembly.segment.width).toBeCloseTo(resolveLeanToLayout(leanTo).roofWidth, 6) + } + } + } + }) + + test('partitions mirrored freestanding mono canopy V corners without gaps or overlaps', () => { + for (const turnZ of [-4, 4]) { + const first = resolveLeanToFreestandingRunPlacement('level_free_v', [0, 0], [4, 0])! + const second = resolveLeanToFreestandingRunPlacement('level_free_v', [4, 0], [4, turnZ])! + const nodes = { [first.id]: first, [second.id]: second } + const assemblies = [ + createLeanToAssembly(first, undefined, nodes), + createLeanToAssembly(second, undefined, nodes), + ] + const leanTos = [first, second] + const roofMeshes = assemblies.map( + (assembly, index) => + new THREE.Mesh( + generateRoofSegmentGeometry(assembly.segment).applyMatrix4( + freestandingSegmentWorldMatrix(leanTos[index]!, assembly.segment), + ), + ), + ) + const baselineMeshes = leanTos + .map((leanTo) => createLeanToAssembly(leanTo).segment) + .map( + (assembly, index) => + new THREE.Mesh( + generateRoofSegmentGeometry(assembly).applyMatrix4( + freestandingSegmentWorldMatrix(leanTos[index]!, assembly), + ), + ), + ) + const bounds = baselineMeshes.reduce( + (box, mesh) => box.union(new THREE.Box3().setFromObject(mesh)), + new THREE.Box3(), + ) + const raycaster = new THREE.Raycaster() + raycaster.ray.direction.set(0, -1, 0) + const hasBaselineRoofAt = (x: number, z: number) => { + raycaster.ray.origin.set(x, 10, z) + return baselineMeshes.some((mesh) => raycaster.intersectObject(mesh, false).length > 0) + } + let gaps = 0 + const overlaps: Array<{ x: number; z: number; delta: number }> = [] + for (let x = bounds.min.x + 0.031; x < bounds.max.x; x += 0.08) { + for (let z = bounds.min.z + 0.047; z < bounds.max.z; z += 0.08) { + const isBaselineInterior = [ + [x, z], + [x - 0.02, z], + [x + 0.02, z], + [x, z - 0.02], + [x, z + 0.02], + ].every(([sampleX, sampleZ]) => hasBaselineRoofAt(sampleX!, sampleZ!)) + if (!isBaselineInterior) continue + raycaster.ray.origin.set(x, 10, z) + const hits = roofMeshes.flatMap((mesh) => + raycaster.intersectObject(mesh, false).slice(0, 1), + ) + if (hits.length === 0) gaps++ + const delta = + hits.length > 1 + ? Math.max(...hits.map((hit) => hit.point.y)) - + Math.min(...hits.map((hit) => hit.point.y)) + : 0 + if (delta > 1e-4) overlaps.push({ x, z, delta }) + } + } + + expect(roofMeshes.map((mesh) => countTopMaterialNonUpwardTriangles(mesh.geometry))).toEqual([ + 0, 0, + ]) + expect({ turnZ, gaps, overlaps }).toEqual({ + turnZ, + gaps: 0, + overlaps: [], + }) + for (const mesh of [...roofMeshes, ...baselineMeshes]) mesh.geometry.dispose() + } + }) test('partitions an inner L into one valley with connected gutters, beam, and post', () => { const { wallA, wallB, leanToA, leanToB, nodes } = innerCornerFixture() const jointA = resolveLeanToCornerJoints(leanToA, wallA, nodes).right @@ -687,6 +850,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) @@ -790,11 +1346,17 @@ describe('lean-to corner joint', () => { // default 5s per-test budget (2-3s locally on Apple Silicon). }, 30_000) - test('rejects corners immediately outside the supported 30 to 150 degree range', () => { + test('resolves shallow and reflex corners outside the former 30 to 150 degree range', () => { for (const angle of [20, 29.99, 150.01, 160]) { const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(angle) - expect(resolveLeanToCornerJoints(leanToA, wallA, nodes)).toEqual({}) - expect(resolveLeanToCornerJoints(leanToB, wallB, nodes)).toEqual({}) + const jointA = resolveLeanToCornerJoints(leanToA, wallA, nodes).right + const jointB = resolveLeanToCornerJoints(leanToB, wallB, nodes).left + + expect(jointA?.neighborId).toBe(leanToB.id) + expect(jointB?.neighborId).toBe(leanToA.id) + expect(jointA?.seam?.flat().every(Number.isFinite)).toBe(true) + expect(jointB?.seam?.flat().every(Number.isFinite)).toBe(true) + expect(jointA?.sharedPostOwner).not.toBe(jointB?.sharedPostOwner) } }) @@ -840,8 +1402,16 @@ describe('lean-to corner joint', () => { test('partitions the shared 60 degree roof-corner patch exactly once', () => { const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(60) const assemblies = [ - { wall: wallA, leanTo: leanToA, assembly: createLeanToAssembly(leanToA, undefined, nodes) }, - { wall: wallB, leanTo: leanToB, assembly: createLeanToAssembly(leanToB, undefined, nodes) }, + { + wall: wallA, + leanTo: leanToA, + assembly: createLeanToAssembly(leanToA, undefined, nodes), + }, + { + wall: wallB, + leanTo: leanToB, + assembly: createLeanToAssembly(leanToB, undefined, nodes), + }, ] const meshes = assemblies.map(({ wall, leanTo, assembly }) => { const matrix = segmentWorldMatrix(wall, leanTo, assembly.segment) @@ -923,8 +1493,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( @@ -951,7 +1521,10 @@ describe('lean-to corner joint', () => { if (owners.length > 1) overlaps.push([x, z]) if (owners.length === 1) { const owner = owners[0]! - samples.set(`${xIndex}:${zIndex}`, { owner, height: hits[owner]!.point.y }) + samples.set(`${xIndex}:${zIndex}`, { + owner, + height: hits[owner]!.point.y, + }) } } } diff --git a/packages/nodes/src/lean-to-extension/system.test.ts b/packages/nodes/src/lean-to-extension/system.test.ts index b1af5b0404..7853a8b466 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 { resolveLeanToFreestandingRunPlacement, 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,229 @@ describe('lean-to scene commit boundary', () => { expect(cornerPosts).toHaveLength(1) }) + test('synchronizes both sides of a continuous freestanding canopy corner', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_free_run_sync', level: 0 }) + const first = resolveLeanToFreestandingRunPlacement(level.id, [0, 0], [4, 0])! + const second = resolveLeanToFreestandingRunPlacement(level.id, [4, 0], [4, 4])! + const firstAssembly = createLeanToAssembly(first) + const secondAssembly = createLeanToAssembly(second) + const nodes = Object.fromEntries( + [ + { ...level, children: [first.id, second.id] }, + firstAssembly.extension, + ...firstAssembly.children, + secondAssembly.extension, + ...secondAssembly.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + const syncedNodes = useScene.getState().nodes + const syncedFirst = syncedNodes[first.id as AnyNodeId] + const syncedSecond = syncedNodes[second.id as AnyNodeId] + expect(syncedFirst).toMatchObject({ + type: 'lean-to-extension', + rightEndCondition: 'joined', + metadata: { leanToCornerJoints: { right: { gutterMitre: -Math.PI / 4 } } }, + }) + expect(syncedSecond).toMatchObject({ + type: 'lean-to-extension', + leftEndCondition: 'joined', + metadata: { leanToCornerJoints: { left: { gutterMitre: -Math.PI / 4 } } }, + }) + const cornerPosts = [syncedFirst, syncedSecond] + .flatMap((node) => node?.children ?? []) + .map((id) => syncedNodes[id as AnyNodeId]) + .filter( + (node) => + node?.type === 'column' && + (managedLeanToPostIndex(node) === leanToCornerPostIndex('left') || + managedLeanToPostIndex(node) === leanToCornerPostIndex('right')), + ) + expect(cornerPosts).toHaveLength(1) + }) + + test.each([ + 'gable', + 'butterfly', + ] as const)('synchronizes continuous %s roof and gutter miters after both runs exist', (canopyForm) => { + stopSync() + const level = LevelNode.parse({ id: `level_${canopyForm}_run_sync`, level: 0 }) + const first = resolveLeanToFreestandingRunPlacement( + level.id, + [0, 0], + [4, 0], + false, + canopyForm, + )! + const second = resolveLeanToFreestandingRunPlacement( + level.id, + [4, 0], + [4, 4], + false, + canopyForm, + )! + const firstAssembly = createLeanToAssembly(first) + const secondAssembly = createLeanToAssembly(second) + const nodes = Object.fromEntries( + [ + { ...level, children: [first.id, second.id] }, + firstAssembly.extension, + ...firstAssembly.children, + secondAssembly.extension, + ...secondAssembly.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + const syncedNodes = useScene.getState().nodes + const syncedFirst = syncedNodes[first.id as AnyNodeId] + expect(syncedFirst).toMatchObject({ + type: 'lean-to-extension', + rightEndCondition: 'joined', + metadata: { leanToFreestandingCanopyJoints: { right: {} } }, + }) + const jointMetadata = syncedFirst?.metadata as + | { leanToFreestandingCanopyJoints?: { right?: { gutterMitre?: number } } } + | undefined + expect(jointMetadata?.leanToFreestandingCanopyJoints?.right?.gutterMitre).toBeCloseTo( + -Math.PI / 4, + 12, + ) + if (syncedFirst?.type !== 'lean-to-extension') return + const roof = syncedFirst.children + .map((id) => syncedNodes[id as AnyNodeId]) + .find((node) => node?.type === 'roof') + const segments = + roof?.type === 'roof' + ? roof.children + .map((id) => syncedNodes[id as AnyNodeId]) + .filter((node) => node?.type === 'roof-segment') + : [] + const primary = segments.find( + (segment) => + (segment.metadata as Record | undefined)?.leanToRoofPlane !== 'opposite', + ) + expect(primary?.trim.right + primary?.trim.left).toBeCloseTo(first.rightOverhang) + expect( + canopyForm === 'gable' ? primary?.trim.frontRightX : primary?.trim.backLeftX, + ).toBeCloseTo(first.projection + first.lowOverhang) + const gutter = primary?.children + .map((id) => syncedNodes[id as AnyNodeId]) + .find( + (node) => + node?.type === 'gutter' && + (node.metadata as Record | undefined)?.leanToDrainageSide !== 'opposite', + ) + expect(gutter?.endCapLeft && gutter?.endCapRight).toBe(false) + const gutterMetadata = gutter?.metadata as + | { leanToGutterMitres?: { left?: number } } + | undefined + expect(gutterMetadata?.leanToGutterMitres?.left).toBeCloseTo( + canopyForm === 'butterfly' ? -Math.PI / 4 : 0, + 12, + ) + }) + + 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 +637,290 @@ 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, + ) + }) + + test('creates each gable eave under its matching managed roof plane', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_initial_gable_sync', level: 0 }) + const leanTo = LeanToExtensionNode.parse({ + id: 'leanto_initial_gable_sync', + parentId: level.id, + canopyForm: 'gable', + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + connectionMode: 'manual', + autoSpan: false, + }) + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes: { + [level.id]: { ...level, children: [leanTo.id] }, + [leanTo.id]: leanTo, + }, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + const syncedNodes = useScene.getState().nodes + const syncedLeanTo = syncedNodes[leanTo.id as AnyNodeId] + expect(syncedLeanTo?.type).toBe('lean-to-extension') + if (syncedLeanTo?.type !== 'lean-to-extension') return + const roof = syncedLeanTo.children + .map((id) => syncedNodes[id as AnyNodeId]) + .find((node) => node?.type === 'roof') + expect(roof?.type).toBe('roof') + if (roof?.type !== 'roof') return + const segments = roof.children + .map((id) => syncedNodes[id as AnyNodeId]) + .filter((node) => node?.type === 'roof-segment') + expect(segments).toHaveLength(2) + for (const segment of segments) { + const gutters = segment.children + .map((id) => syncedNodes[id as AnyNodeId]) + .filter((node) => node?.type === 'gutter') + expect(gutters).toHaveLength(1) + expect(gutters[0]?.parentId).toBe(segment.id) + } + }) + + test('reconciles roof planes and drainage while a freestanding canopy changes form', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_canopy_form_sync', level: 0 }) + const leanTo = LeanToExtensionNode.parse({ + id: 'leanto_canopy_form_sync', + parentId: level.id, + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + connectionMode: 'manual', + autoSpan: false, + }) + 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)) + + useScene.getState().updateNode(leanTo.id as AnyNodeId, { canopyForm: 'gable' }) + + const gableSegments = Object.values(useScene.getState().nodes).filter( + (node) => node.type === 'roof-segment' && node.parentId === assembly.roof.id, + ) + expect(gableSegments).toHaveLength(2) + expect(gableSegments.every((segment) => segment.roofType === 'shed')).toBe(true) + expect( + gableSegments.flatMap((segment) => + segment.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter((node) => node?.type === 'gutter'), + ), + ).toHaveLength(2) + const gableRoof = gableSegments[0] + if (!gableRoof) return + + useScene.getState().updateNode(leanTo.id as AnyNodeId, { canopyForm: 'butterfly' }) + + const butterflySegments = Object.values(useScene.getState().nodes).filter( + (node) => node.type === 'roof-segment' && node.parentId === assembly.roof.id, + ) + expect(butterflySegments).toHaveLength(2) + expect(butterflySegments.every((segment) => segment.roofType === 'shed')).toBe(true) + expect( + butterflySegments.flatMap((segment) => + segment.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter((node) => node?.type === 'gutter'), + ), + ).toHaveLength(1) + + useScene.getState().updateNode(leanTo.id as AnyNodeId, { canopyForm: 'mono' }) + + const monoRoof = useScene.getState().nodes[gableRoof.id as AnyNodeId] + expect(monoRoof?.type).toBe('roof-segment') + if (monoRoof?.type !== 'roof-segment') return + expect(monoRoof.roofType).toBe('shed') + expect( + Object.values(useScene.getState().nodes).filter( + (node) => node.type === 'roof-segment' && node.parentId === assembly.roof.id, + ), + ).toHaveLength(1) + expect( + monoRoof.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter((node) => node?.type === 'gutter'), + ).toHaveLength(1) + }) }) diff --git a/packages/nodes/src/lean-to-extension/system.tsx b/packages/nodes/src/lean-to-extension/system.tsx index 285727bd73..47bebebfcc 100644 --- a/packages/nodes/src/lean-to-extension/system.tsx +++ b/packages/nodes/src/lean-to-extension/system.tsx @@ -15,12 +15,18 @@ import type { import { useEffect } from 'react' import { bendLocalPoint } from './arc' import { + createManagedLeanToCanopyCornerPost, createManagedLeanToCornerPost, + createManagedLeanToDrainagePair, createManagedLeanToPost, createManagedLeanToRoofAssembly, + createManagedLeanToRoofSegment, isManagedLeanToNode, isManagedLeanToPost, + type LeanToDrainageSide, type LeanToPostSide, + type LeanToRoofPlane, + leanToCanopyCornerPostLayoutPatch, leanToCornerPostIndex, leanToCornerPostLayoutPatch, leanToDownspoutLayoutPatch, @@ -28,24 +34,39 @@ import { leanToPostLayoutPatch, leanToRoofMaterialPatch, leanToRoofSegmentLayoutPatch, + managedLeanToDrainageSide, managedLeanToPostIndex, managedLeanToPostSide, + managedLeanToRoofPlane, + resolveLeanToCanopyPostIndexes, resolveLeanToPostBaseY, resolveLeanToPostBaseYAtLocalPosition, resolveLeanToPostGutterSetback, - resolveLeanToPostIndexes, } from './assembly' +import { + canopyCornerJointMetadata, + FREESTANDING_CANOPY_JOINTS_KEY, + resolveFreestandingCanopyJoints, +} from './canopy-joint' +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 { + isDualSlopeLeanToCanopy, + 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, @@ -132,8 +153,9 @@ function segmentNeedsLayoutUpdate( segment: RoofSegmentNode, leanTo: LeanToExtensionNode, nodes: Record, + plane: LeanToRoofPlane = 'primary', ) { - const expected = leanToRoofSegmentLayoutPatch(leanTo, nodes) + const expected = leanToRoofSegmentLayoutPatch(leanTo, nodes, plane) return ( !sameTuple(segment.position, expected.position) || segment.rotation !== expected.rotation || @@ -162,8 +184,9 @@ function gutterNeedsLayoutUpdate( segment: RoofSegmentNode, leanTo: LeanToExtensionNode, nodes: Record, + drainageSide: LeanToDrainageSide = 'primary', ) { - const expected = leanToGutterLayoutPatch(segment, leanTo, gutter, nodes) + const expected = leanToGutterLayoutPatch(segment, leanTo, gutter, nodes, drainageSide) return ( !sameTuple(gutter.position, expected.position) || gutter.rotation !== expected.rotation || @@ -173,6 +196,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,19 +229,20 @@ 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 wall = parent?.type === 'wall' ? (parent as WallNode) : undefined const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) + const canopyJoints = resolveFreestandingCanopyJoints(leanTo, nodes) const sides: LeanToPostSide[] = leanTo.highSideMode === 'independent-high-beam' ? ['low', 'high'] : ['low'] const values: number[] = [] for (const side of sides) { - for (const index of resolveLeanToPostIndexes(leanTo, cornerJoints, side)) { + for (const index of resolveLeanToCanopyPostIndexes(leanTo, cornerJoints, canopyJoints, side)) { values.push(resolveLeanToPostBaseY(leanTo, wall, nodes, index, side)) } } for (const joint of 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, [ @@ -226,6 +252,14 @@ function leanToGroundSignature( ]), ) } + for (const joint of Object.values(canopyJoints)) { + if (!joint?.sharedPostOwner || cornerJoints[joint.side]) continue + for (const side of sides) { + if (isLeanToPostOmitted(leanTo, side, leanToCornerPostIndex(joint.side))) continue + const patch = leanToCanopyCornerPostLayoutPatch(leanTo, joint, side) + values.push(resolveLeanToPostBaseYAtLocalPosition(leanTo, wall, nodes, patch.position)) + } + } return values.map((value) => Math.round(value * 1e5) / 1e5) } @@ -236,6 +270,8 @@ function extensionSignature( ): string { return JSON.stringify([ leanToGroundSignature(leanTo, nodes), + leanTo.hostKind, + leanTo.canopyForm, leanTo.span, leanTo.spanArcCenterZ, leanTo.spanArcRadius, @@ -263,6 +299,7 @@ function extensionSignature( leanTo.postLayoutMode, leanTo.postSpacing, leanTo.postInset, + leanTo.omittedPostSlots, leanTo.postBracing, leanTo.footingStyle, leanTo.highSideMode, @@ -289,6 +326,8 @@ function extensionSignature( .map((node) => ({ id: node.id, parentId: node.parentId, + hostKind: node.hostKind, + canopyForm: node.canopyForm, position: node.position, rotation: node.rotation, span: node.span, @@ -315,6 +354,9 @@ function extensionSignature( function attachmentNeedsUpdate(current: LeanToExtensionNode, next: LeanToExtensionNode): boolean { return ( + current.hostKind !== next.hostKind || + current.canopyForm !== next.canopyForm || + current.highSideMode !== next.highSideMode || current.connectionMode !== next.connectionMode || current.hostRoofId !== next.hostRoofId || current.hostRoofSegmentId !== next.hostRoofSegmentId || @@ -330,6 +372,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) @@ -347,12 +390,53 @@ function resolveEffectiveLeanTo( leanTo: LeanToExtensionNode, nodes: Record, ): LeanToExtensionNode { + if (leanTo.hostKind !== 'freestanding' && leanTo.canopyForm !== 'mono') { + leanTo = { ...leanTo, canopyForm: 'mono' } + } 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) + if (leanTo.hostKind !== 'freestanding') return { ...detached, canopyForm: 'mono' } + const freestanding = { + ...detached, + highSideMode: 'independent-high-beam', + } as LeanToExtensionNode + const withoutStaleJointEnds = { + ...freestanding, + leftEndCondition: + freestanding.leftEndCondition === 'joined' ? 'open' : freestanding.leftEndCondition, + rightEndCondition: + freestanding.rightEndCondition === 'joined' ? 'open' : freestanding.rightEndCondition, + } as LeanToExtensionNode + const canopyJoints = resolveFreestandingCanopyJoints(withoutStaleJointEnds, nodes) + const monoJoints = isDualSlopeLeanToCanopy(withoutStaleJointEnds.canopyForm) + ? {} + : resolveLeanToCornerJoints(withoutStaleJointEnds, undefined, nodes) + const hasLeftJoint = Boolean(canopyJoints.left ?? monoJoints.left) + const hasRightJoint = Boolean(canopyJoints.right ?? monoJoints.right) + return { + ...withoutStaleJointEnds, + leftEndCondition: hasLeftJoint ? 'joined' : withoutStaleJointEnds.leftEndCondition, + rightEndCondition: hasRightJoint ? 'joined' : withoutStaleJointEnds.rightEndCondition, + metadata: { + ...(withoutStaleJointEnds.metadata && typeof withoutStaleJointEnds.metadata === 'object' + ? withoutStaleJointEnds.metadata + : {}), + [LEAN_TO_CORNER_JOINTS_KEY]: isDualSlopeLeanToCanopy(withoutStaleJointEnds.canopyForm) + ? {} + : leanToCornerJointMetadata(monoJoints), + [FREESTANDING_CANOPY_JOINTS_KEY]: canopyCornerJointMetadata(canopyJoints), + }, + } } 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 +453,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 +541,9 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { update.push({ id, data: { + hostKind: effectiveLeanTo.hostKind, + canopyForm: effectiveLeanTo.canopyForm, + highSideMode: effectiveLeanTo.highSideMode, connectionMode: effectiveLeanTo.connectionMode, hostRoofId: effectiveLeanTo.hostRoofId, hostRoofSegmentId: effectiveLeanTo.hostRoofSegmentId, @@ -475,6 +559,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, @@ -487,8 +572,22 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { create.push( { node: assembly.roof, parentId: leanTo.id }, { node: assembly.segment, parentId: assembly.roof.id }, + ...(assembly.oppositeSegment + ? [{ node: assembly.oppositeSegment, parentId: assembly.roof.id }] + : []), { node: assembly.gutter, parentId: assembly.segment.id }, { node: assembly.downspout, parentId: assembly.segment.id }, + ...(assembly.oppositeGutter && assembly.oppositeSegment + ? [{ node: assembly.oppositeGutter, parentId: assembly.oppositeSegment.id }] + : []), + ...(assembly.oppositeDownspout && assembly.oppositeSegment + ? [ + { + node: assembly.oppositeDownspout, + parentId: assembly.oppositeSegment.id, + }, + ] + : []), ) } else { if ( @@ -501,13 +600,133 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { data: leanToRoofMaterialPatch(hostRoof) as Partial, }) } - const segment = roof.children + const managedSegments = roof.children .map((childId) => nodes[childId as AnyNodeId]) - .find( + .filter( (child): child is RoofSegmentNode => child?.type === 'roof-segment' && isManagedLeanToNode(child, leanTo.id, 'roof-segment'), ) + const segment = managedSegments.find( + (candidate) => managedLeanToRoofPlane(candidate) === 'primary', + ) + const oppositeSegment = managedSegments.find( + (candidate) => managedLeanToRoofPlane(candidate) === 'opposite', + ) + if (isDualSlopeLeanToCanopy(effectiveLeanTo.canopyForm)) { + if (!oppositeSegment) { + const createdOppositeSegment = createManagedLeanToRoofSegment( + effectiveLeanTo, + roof.id, + 'opposite', + nodes, + ) + create.push({ + node: createdOppositeSegment, + parentId: roof.id as AnyNodeId, + }) + if (effectiveLeanTo.canopyForm === 'gable') { + const pair = createManagedLeanToDrainagePair( + createdOppositeSegment, + effectiveLeanTo, + 'opposite', + nodes, + ) + create.push( + { node: pair.gutter, parentId: createdOppositeSegment.id as AnyNodeId }, + { node: pair.downspout, parentId: createdOppositeSegment.id as AnyNodeId }, + ) + } + } else { + const oppositePatch = leanToRoofSegmentLayoutPatch(effectiveLeanTo, nodes, 'opposite') + const expectedOppositeSegment = { + ...oppositeSegment, + ...oppositePatch, + } as RoofSegmentNode + if (segmentNeedsLayoutUpdate(oppositeSegment, effectiveLeanTo, nodes, 'opposite')) { + update.push({ + id: oppositeSegment.id as AnyNodeId, + data: oppositePatch as Partial, + }) + } + const oppositeChildren = oppositeSegment.children.map( + (childId) => nodes[childId as AnyNodeId], + ) + const oppositeGutter = oppositeChildren.find( + (child): child is GutterNode => + child?.type === 'gutter' && + isManagedLeanToNode(child, leanTo.id, 'gutter') && + managedLeanToDrainageSide(child) === 'opposite', + ) + const oppositeDownspout = oppositeChildren.find( + (child): child is DownspoutNode => + child?.type === 'downspout' && + isManagedLeanToNode(child, leanTo.id, 'downspout') && + managedLeanToDrainageSide(child) === 'opposite', + ) + if (effectiveLeanTo.canopyForm === 'gable') { + if (!oppositeGutter) { + const pair = createManagedLeanToDrainagePair( + expectedOppositeSegment, + effectiveLeanTo, + 'opposite', + nodes, + ) + create.push( + { node: pair.gutter, parentId: oppositeSegment.id as AnyNodeId }, + { node: pair.downspout, parentId: oppositeSegment.id as AnyNodeId }, + ) + } else { + const gutterPatch = leanToGutterLayoutPatch( + expectedOppositeSegment, + effectiveLeanTo, + oppositeGutter, + nodes, + 'opposite', + ) + const expectedGutter = { ...oppositeGutter, ...gutterPatch } as GutterNode + if ( + gutterNeedsLayoutUpdate( + oppositeGutter, + expectedOppositeSegment, + effectiveLeanTo, + nodes, + 'opposite', + ) + ) { + update.push({ + id: oppositeGutter.id as AnyNodeId, + data: gutterPatch as Partial, + }) + } + if ( + oppositeDownspout && + downspoutNeedsLayoutUpdate( + oppositeDownspout, + expectedGutter, + expectedOppositeSegment, + effectiveLeanTo, + ) + ) { + update.push({ + id: oppositeDownspout.id as AnyNodeId, + data: leanToDownspoutLayoutPatch( + expectedOppositeSegment, + expectedGutter, + effectiveLeanTo, + oppositeDownspout, + ) as Partial, + }) + } + } + } else { + if (oppositeGutter) remove.push(oppositeGutter.id as AnyNodeId) + if (oppositeDownspout) remove.push(oppositeDownspout.id as AnyNodeId) + } + } + } else if (oppositeSegment) { + remove.push(oppositeSegment.id as AnyNodeId) + } if (segment) { const segmentPatch = leanToRoofSegmentLayoutPatch(effectiveLeanTo, nodes) const expectedSegment = { @@ -520,12 +739,15 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { data: segmentPatch as Partial, }) } - const gutter = segment.children - .map((childId) => nodes[childId as AnyNodeId]) - .find( - (child): child is GutterNode => - child?.type === 'gutter' && isManagedLeanToNode(child, leanTo.id, 'gutter'), - ) + const managedSegmentChildren = segment.children.map( + (childId) => nodes[childId as AnyNodeId], + ) + const gutter = managedSegmentChildren.find( + (child): child is GutterNode => + child?.type === 'gutter' && + isManagedLeanToNode(child, leanTo.id, 'gutter') && + managedLeanToDrainageSide(child) === 'primary', + ) if (gutter) { const gutterPatch = leanToGutterLayoutPatch( expectedSegment, @@ -540,12 +762,12 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { data: gutterPatch as Partial, }) } - const downspout = segment.children - .map((childId) => nodes[childId as AnyNodeId]) - .find( - (child): child is DownspoutNode => - child?.type === 'downspout' && isManagedLeanToNode(child, leanTo.id, 'downspout'), - ) + const downspout = managedSegmentChildren.find( + (child): child is DownspoutNode => + child?.type === 'downspout' && + isManagedLeanToNode(child, leanTo.id, 'downspout') && + child.gutterId === gutter.id, + ) if ( downspout && downspoutNeedsLayoutUpdate( @@ -566,25 +788,55 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { }) } } + + const oppositeGutter = managedSegmentChildren.find( + (child): child is GutterNode => + child?.type === 'gutter' && + isManagedLeanToNode(child, leanTo.id, 'gutter') && + managedLeanToDrainageSide(child) === 'opposite', + ) + const oppositeDownspout = managedSegmentChildren.find( + (child): child is DownspoutNode => + child?.type === 'downspout' && + isManagedLeanToNode(child, leanTo.id, 'downspout') && + managedLeanToDrainageSide(child) === 'opposite', + ) + if (oppositeGutter) remove.push(oppositeGutter.id as AnyNodeId) + if (oppositeDownspout) remove.push(oppositeDownspout.id as AnyNodeId) } } - const cornerJoints = - parent?.type === 'wall' ? resolveLeanToCornerJoints(effectiveLeanTo, parent, nodes) : {} + const cornerJoints = resolveLeanToCornerJoints( + effectiveLeanTo, + parent?.type === 'wall' ? parent : undefined, + nodes, + ) + const canopyJoints = resolveFreestandingCanopyJoints(effectiveLeanTo, nodes) const postSides: LeanToPostSide[] = effectiveLeanTo.highSideMode === 'independent-high-beam' ? ['low', 'high'] : ['low'] const desiredPostKeys = new Set() for (const side of postSides) { - for (const index of resolveLeanToPostIndexes(effectiveLeanTo, cornerJoints, side)) { + for (const index of resolveLeanToCanopyPostIndexes( + effectiveLeanTo, + cornerJoints, + canopyJoints, + 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 + side === 'low' || + (side === 'high' && isDualSlopeLeanToCanopy(effectiveLeanTo.canopyForm)) + ? resolveLeanToPostGutterSetback(effectiveLeanTo, current) + : 0 if (!current) { create.push({ node: { @@ -615,6 +867,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( @@ -622,14 +875,12 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { joint.sharedPostPosition[0], joint.sharedPostPosition[2], ) - const postBaseY = - parent?.type === 'wall' - ? resolveLeanToPostBaseYAtLocalPosition(effectiveLeanTo, parent, nodes, [ - bentCornerPost.x, - joint.sharedPostPosition[1], - bentCornerPost.y, - ]) - : 0 + const postBaseY = resolveLeanToPostBaseYAtLocalPosition( + effectiveLeanTo, + parent?.type === 'wall' ? parent : undefined, + nodes, + [bentCornerPost.x, joint.sharedPostPosition[1], bentCornerPost.y], + ) const current = managedPosts.get(key) const gutterSetback = resolveLeanToPostGutterSetback(effectiveLeanTo, current) const patch = leanToCornerPostLayoutPatch(effectiveLeanTo, joint, postBaseY, gutterSetback) @@ -648,6 +899,51 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { }) } } + for (const joint of Object.values(canopyJoints)) { + if (!joint?.sharedPostOwner || cornerJoints[joint.side]) continue + for (const side of postSides) { + const index = leanToCornerPostIndex(joint.side) + if (isLeanToPostOmitted(effectiveLeanTo, side, index)) continue + const key = `${side}:${index}` + desiredPostKeys.add(key) + const current = managedPosts.get(key) + const gutterSetback = resolveLeanToPostGutterSetback(effectiveLeanTo, current) + const ungroundedPatch = leanToCanopyCornerPostLayoutPatch( + effectiveLeanTo, + joint, + side, + 0, + gutterSetback, + ) + const postBaseY = resolveLeanToPostBaseYAtLocalPosition( + effectiveLeanTo, + parent?.type === 'wall' ? parent : undefined, + nodes, + ungroundedPatch.position, + ) + const patch = leanToCanopyCornerPostLayoutPatch( + effectiveLeanTo, + joint, + side, + postBaseY, + gutterSetback, + ) + if (!current) { + create.push({ + node: { + ...createManagedLeanToCanopyCornerPost(effectiveLeanTo, joint, side), + ...patch, + } as ColumnNode, + parentId: leanTo.id, + }) + } else if (postPatchNeedsLayoutUpdate(current, patch)) { + update.push({ + id: current.id as AnyNodeId, + data: patch as Partial, + }) + } + } + } for (const [key, post] of managedPosts) { if (!desiredPostKeys.has(key)) remove.push(post.id as AnyNodeId) } diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx index 3f31e3860e..c27204d160 100644 --- a/packages/nodes/src/lean-to-extension/tool.tsx +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -3,52 +3,121 @@ 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 { + CursorSphere, + isGridSnapActive, + isMagneticSnapActive, + markToolCancelConsumed, 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, + LEAN_TO_RUN_CONNECT_SNAP_RADIUS, + LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS, + type LeanToPlanPlacementTarget, + nextLeanToCanopyForm, + nextLeanToPlacementRotation, + resolveLeanToCommitTarget, + resolveLeanToFreestandingRunEndpointSnap, + resolveLeanToFreestandingRunTarget, + 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 = () => { const { activeLevelId, sceneApi, selectNode } = useRegistryToolContext() const viewMode = useEditor((state) => state.viewMode) const [preview, setPreview] = useState(null) + const [chainCursor, setChainCursor] = useState<[number, number, number] | null>(null) + const [runSnap, setRunSnap] = useState<[number, number, number] | null>(null) useEffect(() => { if (!(activeLevelId && viewMode === '3d')) return useInteractionScope.getState().begin({ kind: 'drafting', tool: 'lean-to-extension' }) + let lastMeshEventTime = -1 + let freestandingRotationY = 0 + let freestandingCanopyForm: LeanToExtensionNode['canopyForm'] = 'mono' + let lastFreestandingEvent: GridEvent | SlabEvent | null = null + let lastPreviewTarget: PlacementCommitTarget | null = null + let chainStart: [number, number] | null = null + let chainEnd: [number, number] | null = null + let chainEndSnapped = false + let chainFlipProjection = false + let lastRunSnapKey: string | null = null + let commitQueued = false + + const isContinuous = () => useEditor.getState().getContinuation('canopy') === 'continuous' + + const snapPoint = (point: readonly [number, number], altKey: boolean): [number, number] => { + const step = !altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) + return [snap(point[0]), snap(point[1])] + } + + const setChainCursorPreview = (point: readonly [number, number] | null) => { + if (!point) { + setChainCursor(null) + return + } + const position = new Vector3(point[0], 0, point[1]) + sceneRegistry.nodes.get(activeLevelId)?.localToWorld(position) + setChainCursor([position.x, position.y, position.z]) + } + + const setRunSnapPreview = ( + snap: { nodeId: string; point: [number, number]; side: 'left' | 'right' } | null, + ) => { + const key = snap ? `${snap.nodeId}:${snap.side}` : null + if (key && key !== lastRunSnapKey) triggerSFX('sfx:grid-snap') + lastRunSnapKey = key + if (!snap) { + setRunSnap(null) + return + } + const position = new Vector3(snap.point[0], 0.06, snap.point[1]) + sceneRegistry.nodes.get(activeLevelId)?.localToWorld(position) + setRunSnap([position.x, position.y, position.z]) + } const resolveBaseY = (wall: WallNode) => { const nodes = sceneApi.nodes() as Record @@ -56,92 +125,568 @@ 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 (!isContinuous()) { + 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 updateContinuousTarget = (point: [number, number], altKey = false) => { + if (!chainStart) return null + const nodes = sceneApi.nodes() as Record + const snap = altKey + ? null + : resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId, + canopyForm: freestandingCanopyForm, + flipProjection: chainFlipProjection, + maxDistance: isMagneticSnapActive() + ? LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS + : LEAN_TO_RUN_CONNECT_SNAP_RADIUS, + nodes, + proposedEnd: point, + start: chainStart, + }) + const end = snap?.point ?? point + setChainCursorPreview(end) + const target = resolveLeanToFreestandingRunTarget({ + activeLevelId, + canopyForm: freestandingCanopyForm, + start: chainStart, + end, + flipProjection: chainFlipProjection, + nodes, + }) + chainEnd = end + chainEndSnapped = Boolean(snap) + setRunSnapPreview(snap) + lastPreviewTarget = target?.node.parentId + ? { node: target.node, parentId: target.node.parentId as AnyNodeId, valid: target.valid } + : null + setPreview(target ? levelPreviewPose(target.node) : null) + return target + } + + const pointFromObjectEvent = ( + event: WallEvent | DoorEvent | RoofEvent | RoofSegmentEvent | SlabEvent, + ): [number, number] => { + const position = event.object.localToWorld(new Vector3(...event.localPosition)) + sceneRegistry.nodes.get(activeLevelId)?.worldToLocal(position) + return snapPoint([position.x, position.z], event.nativeEvent.altKey) + } + + const updateContinuousObjectTarget = ( + event: WallEvent | DoorEvent | RoofEvent | RoofSegmentEvent | SlabEvent, + ) => updateContinuousTarget(pointFromObjectEvent(event), event.nativeEvent.altKey) + + const finishRun = () => { + chainStart = null + chainEnd = null + chainEndSnapped = false + chainFlipProjection = false + lastPreviewTarget = null + setChainCursorPreview(null) + setRunSnapPreview(null) + setPreview(null) + } + + const advanceRun = () => { + if (!chainEnd) return + if (chainEndSnapped) { + finishRun() + return + } + chainStart = chainEnd + chainEnd = null + chainEndSnapped = false + setRunSnapPreview(null) + setChainCursorPreview(chainStart) + } + + const updateFreeTarget = (event: GridEvent | SlabEvent) => { + const point = snapPoint( + [event.localPosition[0], event.localPosition[2]], + event.nativeEvent.altKey, + ) + if (chainStart && isContinuous()) { + lastFreestandingEvent = event + return updateContinuousTarget(point, event.nativeEvent.altKey) + } + if (chainStart) finishRun() + const nodes = sceneApi.nodes() as Record + const target = resolveLeanToPlanPlacement({ + activeLevelId, + freestandingPoint: point, + freestandingRotationY, + freestandingCanopyForm, + 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 | null => { + if (chainStart && isContinuous()) { + return updateContinuousObjectTarget(event) + } + 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) => { + if (chainStart && isContinuous()) return updateContinuousObjectTarget(event) + 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) => { + if (chainStart && isContinuous()) return updateContinuousObjectTarget(event) + 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 + } + 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) => { + if (chainStart && isContinuous()) return updateContinuousObjectTarget(event) + 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, node, resolveBaseY(event.node)) + 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) + if (chainStart) advanceRun() + } + + const onDoorMove = (event: DoorEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + if (chainStart && isContinuous()) { + updateContinuousObjectTarget(event) + return + } + 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) + if (chainStart) advanceRun() + } + + 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) + if (chainStart) advanceRun() + } + 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) + if (chainStart) advanceRun() + } + 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) + if (chainStart) advanceRun() + } + const onGridMove = (event: GridEvent) => { + if (event.nativeEvent.timeStamp === lastMeshEventTime) return + updateFreeTarget(event) + } + const onGridClick = (event: GridEvent) => { + if (event.nativeEvent.timeStamp === lastMeshEventTime) return + if (isContinuous() && !chainStart) { + chainStart = snapPoint( + [event.localPosition[0], event.localPosition[2]], + event.nativeEvent.altKey, + ) + lastFreestandingEvent = event + lastPreviewTarget = null + setChainCursorPreview(chainStart) + setPreview(null) + triggerSFX('sfx:structure-build-start') + return + } + const visibleTarget = lastPreviewTarget + updateFreeTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!target?.valid) return + commitNode(target.node, target.parentId) + if (chainStart) advanceRun() + } + + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return + } + if (event.key === 'Escape' && chainStart) { + event.preventDefault() + event.stopImmediatePropagation() + markToolCancelConsumed() + finishRun() + return + } + if ( + chainStart && + (event.key === 'r' || event.key === 'R' || event.key === 't' || event.key === 'T') + ) { + event.preventDefault() + chainFlipProjection = !chainFlipProjection + triggerSFX('sfx:item-rotate') + if (chainEnd) updateContinuousTarget(chainEnd) + return + } + const nextRotation = nextLeanToPlacementRotation( + freestandingRotationY, + event.key, + event.metaKey || event.ctrlKey, + ) + const nextForm = nextLeanToCanopyForm(freestandingCanopyForm, event.key) + if (nextRotation === freestandingRotationY && nextForm === freestandingCanopyForm) return + + event.preventDefault() + freestandingRotationY = nextRotation + freestandingCanopyForm = nextForm + triggerSFX('sfx:item-rotate') + if (chainStart && chainEnd) updateContinuousTarget(chainEnd) + else if (lastFreestandingEvent) 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, true) 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, true) setPreview(null) + setChainCursor(null) + setRunSnap(null) useInteractionScope .getState() .endIf((scope) => scope.kind === 'drafting' && scope.tool === 'lean-to-extension') } }, [activeLevelId, sceneApi, selectNode, viewMode]) - if (!preview || viewMode !== '3d') return null + if (viewMode !== '3d') return null return ( - - - + <> + {chainCursor ? ( + + ) : null} + {runSnap ? ( + + + + + ) : null} + {preview ? ( + + + + ) : null} + ) } 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..6b4b5a5a25 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,32 @@ function pitchHandle(): LinearResizeHandle { } describe('roof-segment resize handles', () => { + test('records the conical full-circle schema update', () => { + expect(roofSegmentDefinition.schemaVersion).toBe(4) + }) + + 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 +124,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..4cbdff7979 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: 4, 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..418b161c5d 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,79 @@ 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('renders a conical sector as a clipped polygon', () => { + const roof = RoofNode.parse({ id: 'roof_test', children: ['rseg_test'] }) + const node = dutchSegment({ + parentId: roof.id, + roofType: 'conical', + width: 6, + depth: 6, + conicalStartAngle: 0, + conicalSweepAngle: Math.PI, + }) + const geometry = buildRoofSegmentFloorplan(node, { + parent: roof, + viewState: { selected: true }, + } as GeometryContext) as Extract + const polygons = geometry.children.filter( + (child): child is Extract => child.kind === 'polygon', + ) + + expect(geometry.children.some((child) => child.kind === 'circle')).toBe(false) + expect(polygons).toHaveLength(2) + expect(polygons[0]?.points[0]).toEqual([0, 0]) + expect(polygons[0]?.points).toHaveLength(26) + expect(geometry.children.some((child) => child.kind === 'rotate-arrow')).toBe(false) + }) + + test('renders a clipped conical sector as a circle when full coverage is enabled', () => { + const roof = RoofNode.parse({ id: 'roof_test', children: ['rseg_test'] }) + const node = dutchSegment({ + parentId: roof.id, + roofType: 'conical', + width: 6, + depth: 6, + conicalFullCircle: true, + conicalStartAngle: 0, + conicalSweepAngle: Math.PI, + }) + const geometry = buildRoofSegmentFloorplan(node, { + parent: roof, + viewState: { selected: true }, + } as GeometryContext) as Extract + + expect(geometry.children.filter((child) => child.kind === 'circle')).toHaveLength(2) + expect(geometry.children.some((child) => child.kind === 'polygon')).toBe(false) + }) + 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..64c3866c65 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -2,6 +2,7 @@ import { type FloorplanGeometry, type FloorplanPoint, type GeometryContext, + getConicalRoofCoverage, getDutchRoofMetrics, type RoofNode, type RoofSegmentNode, @@ -52,6 +53,8 @@ export function buildRoofSegmentFloorplan( cx + lx * cos - lz * sin, cz + lx * sin + lz * cos, ] + const conicalFootprint = getConicalRoofPlanFootprint(node).map(([x, z]) => toPlan(x, z)) + const isFullCone = getConicalRoofCoverage(node).fullCircle const corners: Array<[number, number]> = [ [-halfWidth, -halfDepth], @@ -73,19 +76,33 @@ export function buildRoofSegmentFloorplan( const baseInk = '#111111' const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseInk + const footprint: FloorplanGeometry = + node.roofType === 'conical' && isFullCone + ? { + kind: 'circle', + cx, + cy: cz, + r: halfWidth, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + } + : { + kind: 'polygon', + points: node.roofType === 'conical' ? conicalFootprint : 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 +112,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' && isFullCone + ? { + kind: 'circle', + cx, + cy: cz, + r: halfWidth, + fill: '#fed7aa', + fillOpacity: 0.55, + stroke, + strokeWidth: 0.035, + } + : { + kind: 'polygon', + points: node.roofType === 'conical' ? conicalFootprint : 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 +142,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 +164,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 } @@ -178,6 +221,20 @@ export function buildRoofSegmentFloorplan( export type PlanPt = readonly [number, number] export type PlanSeg = readonly [PlanPt, PlanPt] +export function getConicalRoofPlanFootprint(node: RoofSegmentNode): PlanPt[] { + const coverage = getConicalRoofCoverage(node) + const sweep = Math.max( + -Math.PI * 2, + Math.min(Math.PI * 2, Math.abs(coverage.sweepAngle) < 1e-4 ? 1e-4 : coverage.sweepAngle), + ) + const count = Math.max(1, Math.ceil((48 * Math.abs(sweep)) / (Math.PI * 2))) + const arc = Array.from({ length: count + 1 }, (_, index) => { + const angle = coverage.startAngle + (index / count) * sweep + return [Math.cos(angle) * (node.width / 2), Math.sin(angle) * (node.width / 2)] as PlanPt + }) + return Math.abs(sweep) >= Math.PI * 2 - 1e-4 ? arc.slice(0, -1) : [[0, 0], ...arc] +} + /** * Ridge / hip / break linework for a roof segment in segment-local space * (lx = width axis, lz = depth axis), mirroring the faces the 3D builder @@ -233,6 +290,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..824e61254a 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, createDefaultRidgeVentsForSegment, + getConicalRoofCoverage, isAutoGutterEnabled, isAutoRidgeVentEnabled, isDefaultRidgeVentNode, @@ -43,6 +44,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 +132,44 @@ 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, + conicalFullCircle: true, + 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(() => { @@ -288,6 +314,7 @@ export default function RoofSegmentPanel() { const showTrimPlanes = shouldShowTrimPlanes(node.metadata) const managedLeanToRoofSegment = isManagedLeanToRoofSegment(node.metadata) + const conicalCoverage = getConicalRoofCoverage(node) return ( + 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} + /> + + )} + {node.roofType === 'conical' && ( + + handleUpdate({ conicalFullCircle: !checked })} + /> + {!conicalCoverage.fullCircle && ( + <> + + handleUpdate({ conicalStartAngle: (degrees * Math.PI) / 180 }) + } + precision={0} + step={1} + unit="°" + value={Math.round((conicalCoverage.startAngle * 180) / Math.PI)} + /> + + handleUpdate({ + conicalSweepAngle: + (Math.sign(conicalCoverage.sweepAngle) * degrees * Math.PI) / 180, + }) + } + precision={0} + step={1} + unit="°" + value={Math.round((Math.abs(conicalCoverage.sweepAngle) * 180) / Math.PI)} + /> + + )} + + )} + - { - 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' || !conicalCoverage.fullCircle) && ( + <> + { + 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..548c0094f7 100644 --- a/packages/nodes/src/roof/floorplan.ts +++ b/packages/nodes/src/roof/floorplan.ts @@ -4,11 +4,11 @@ import { type GeometryContext, type RoofNode, type RoofSegmentNode, - roofOverlapEntryOwns, + roofPlanOverlapEntryOwns, subtractPolygonsFromPolygon, unionPolygons, } from '@pascal-app/core' -import { getRoofSegmentPlanLinework } from '../roof-segment/floorplan' +import { getConicalRoofPlanFootprint, getRoofSegmentPlanLinework } from '../roof-segment/floorplan' type Pt = [number, number] type Seg = [Pt, Pt] @@ -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,12 @@ 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' + ? getConicalRoofPlanFootprint(seg).map(([x, z]) => tp(x, z)) + : [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 +177,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..8b56cbf847 100644 --- a/packages/nodes/src/shared/roof-surface.ts +++ b/packages/nodes/src/shared/roof-surface.ts @@ -1,4 +1,5 @@ import { + getConicalRoofCoverage, getRoofModuleFaces, getRoofSegmentSurfaceY, getRoofShapeInsets, @@ -90,6 +91,7 @@ function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { } function roofSurfaceFaceCacheKey(segment: RoofSegmentNode): string { + const conicalCoverage = getConicalRoofCoverage(segment) return [ segment.roofType, segment.width, @@ -100,6 +102,8 @@ function roofSurfaceFaceCacheKey(segment: RoofSegmentNode): string { segment.overhang, segment.shingleThickness, segment.pitch, + conicalCoverage.startAngle, + conicalCoverage.sweepAngle, segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, segment.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio, segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio, @@ -111,6 +115,7 @@ function roofSurfaceFaceCacheKey(segment: RoofSegmentNode): string { } function buildRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { + const conicalCoverage = getConicalRoofCoverage(segment) const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment) @@ -136,7 +141,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') { @@ -191,6 +201,8 @@ function buildRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { tanTheta, shapeRatios, dutchTopRakeThickness: segment.dutchTopRakeThickness, + conicalStartAngle: conicalCoverage.startAngle, + conicalSweepAngle: conicalCoverage.sweepAngle, }) .filter((face) => faceNormalY(face) > SHINGLE_SURFACE_EPSILON) .map((face) => { @@ -434,6 +446,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/wall/conical-roof.ts b/packages/nodes/src/wall/conical-roof.ts new file mode 100644 index 0000000000..79359396d5 --- /dev/null +++ b/packages/nodes/src/wall/conical-roof.ts @@ -0,0 +1,69 @@ +import { + type AnyNode, + type AnyNodeId, + getLevelBelow, + getLevelElevations, + getWallArcData, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, + type LevelNode, + RoofNode, + RoofSegmentNode, + resolveLevelId, + type SceneApi, + type WallNode, +} from '@pascal-app/core' + +const DEFAULT_CONICAL_ROOF_PITCH = 40 + +export function createConicalRoofSectorAboveWall( + wall: WallNode, + nodes: Readonly>, + sceneApi: SceneApi, + targetLevelId: LevelNode['id'], +): RoofSegmentNode['id'] | null { + const arc = getWallArcData(wall) + if (!(arc && nodes[targetLevelId]?.type === 'level')) return null + const completeNodes = nodes as Record + const sourceLevelId = resolveLevelId(wall, completeNodes) + const levelBelowId = getLevelBelow(targetLevelId, completeNodes)?.id + if (sourceLevelId !== targetLevelId && sourceLevelId !== levelBelowId) return null + const elevations = getLevelElevations(completeNodes) + const sourceLevelY = elevations.get(resolveLevelId(wall, completeNodes))?.baseY ?? 0 + const targetLevelY = elevations.get(targetLevelId)?.baseY ?? 0 + + const roofCount = Object.values(nodes).filter((node) => node?.type === 'roof').length + const segment = RoofSegmentNode.parse({ + roofType: 'conical', + width: arc.radius * 2, + depth: arc.radius * 2, + wallHeight: 0, + pitch: DEFAULT_CONICAL_ROOF_PITCH, + conicalStartAngle: arc.startAngle, + conicalSweepAngle: arc.delta, + conicalFullCircle: true, + }) + const roof = RoofNode.parse({ + name: `Roof ${roofCount + 1}`, + position: [ + arc.center.x, + Math.max( + 0, + sourceLevelY + + getWallBaseElevationForNodes(wall, completeNodes) + + getWallEffectiveHeightForNodes(wall, completeNodes) - + targetLevelY, + ), + arc.center.y, + ], + children: [segment.id], + }) + + const ops = [ + { node: roof, parentId: targetLevelId as AnyNodeId }, + { node: segment, parentId: roof.id as AnyNodeId }, + ] + if (sceneApi.createMany) sceneApi.createMany(ops) + else for (const op of ops) sceneApi.upsert(op.node, op.parentId) + return segment.id +} diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index bca76db924..2764a2c83c 100644 --- a/packages/nodes/src/wall/definition.test.ts +++ b/packages/nodes/src/wall/definition.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' import { getFloorplanNodeExtension } from '@pascal-app/editor' +import { createConicalRoofSectorAboveWall } from './conical-roof' import { wallDefinition } from './definition' test('wallDefinition records the lean-to child schema migration', () => { @@ -57,3 +58,166 @@ test('wall top surface follows the effective level-bound height', () => { expect(typeof height).toBe('function') expect(typeof height === 'function' ? height(wall, { nodes }) : height).toBe(3.2) }) + +test('curved wall roof builder creates a matching conical sector above it', () => { + const level = { + object: 'node', + id: 'level_test', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: ['wall_test'], + level: 0, + height: 3, + } as AnyNode + const wall = wallDefinition.schema.parse({ + id: 'wall_test', + parentId: level.id, + start: [-2, 0], + end: [2, 0], + curveOffset: 2, + height: 3, + }) + const nodes = { [level.id]: level, [wall.id]: wall } as Record + const created: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [] + const sceneApi = { + createMany: (ops) => created.push(...ops), + nodes: () => nodes, + } as SceneApi + const segmentId = createConicalRoofSectorAboveWall(wall, nodes, sceneApi, level.id as AnyNodeId) + const roof = created.find((entry) => entry.node.type === 'roof')?.node + const segment = created.find((entry) => entry.node.type === 'roof-segment')?.node + + expect(wallDefinition.quickActions).toBeUndefined() + expect(roof).toMatchObject({ position: [0, 3, 0] }) + expect(segment).toMatchObject({ + roofType: 'conical', + width: 4, + depth: 4, + wallHeight: 0, + conicalFullCircle: true, + conicalSweepAngle: Math.PI, + }) + expect(segmentId).toBe(segment?.id) +}) + +test('curved wall roof builder parents the roof to the active level', () => { + const sourceLevel = { + object: 'node', + id: 'level_source', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: ['wall_test'], + level: 0, + height: 3, + } as AnyNode + const activeLevel = { + ...sourceLevel, + id: 'level_active', + children: [], + level: 1, + } as AnyNode + const wall = wallDefinition.schema.parse({ + id: 'wall_test', + parentId: sourceLevel.id, + start: [-2, 0], + end: [2, 0], + curveOffset: 2, + height: 3, + }) + const nodes = Object.fromEntries( + [sourceLevel, activeLevel, wall].map((node) => [node.id, node]), + ) as Record + const created: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [] + const sceneApi = { + createMany: (ops) => created.push(...ops), + nodes: () => nodes, + } as SceneApi + + createConicalRoofSectorAboveWall(wall, nodes, sceneApi, activeLevel.id as AnyNodeId) + + const createdRoof = created.find((entry) => entry.node.type === 'roof') + expect(createdRoof?.parentId).toBe(activeLevel.id) + expect(createdRoof?.node).toMatchObject({ position: [0, 0, 0] }) +}) + +test('curved wall roof builder clamps a lower-floor wall to the active floor', () => { + const sourceLevel = { + object: 'node', + id: 'level_source', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: ['wall_test'], + level: 0, + height: 3, + } as AnyNode + const activeLevel = { + ...sourceLevel, + id: 'level_active', + children: [], + level: 1, + } as AnyNode + const wall = wallDefinition.schema.parse({ + id: 'wall_test', + parentId: sourceLevel.id, + start: [-2, 0], + end: [2, 0], + curveOffset: 2, + height: 1, + }) + const nodes = Object.fromEntries( + [sourceLevel, activeLevel, wall].map((node) => [node.id, node]), + ) as Record + const created: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [] + const sceneApi = { + createMany: (ops) => created.push(...ops), + nodes: () => nodes, + } as SceneApi + + createConicalRoofSectorAboveWall(wall, nodes, sceneApi, activeLevel.id as AnyNodeId) + + const createdRoof = created.find((entry) => entry.node.type === 'roof') + expect(createdRoof?.node).toMatchObject({ position: [0, 0, 0] }) +}) + +test('curved wall roof builder rejects walls more than one level below', () => { + const sourceLevel = { + object: 'node', + id: 'level_source', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: ['wall_test'], + level: 0, + height: 3, + } as AnyNode + const middleLevel = { ...sourceLevel, id: 'level_middle', children: [], level: 1 } as AnyNode + const activeLevel = { ...sourceLevel, id: 'level_active', children: [], level: 2 } as AnyNode + const wall = wallDefinition.schema.parse({ + id: 'wall_test', + parentId: sourceLevel.id, + start: [-2, 0], + end: [2, 0], + curveOffset: 2, + height: 3, + }) + const nodes = Object.fromEntries( + [sourceLevel, middleLevel, activeLevel, wall].map((node) => [node.id, node]), + ) as Record + const created: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [] + const sceneApi = { + createMany: (ops) => created.push(...ops), + nodes: () => nodes, + } as SceneApi + + expect( + createConicalRoofSectorAboveWall(wall, nodes, sceneApi, activeLevel.id as AnyNodeId), + ).toBeNull() + expect(created).toEqual([]) +}) diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 69a0a8b4d5..6db1852c4e 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -166,7 +166,6 @@ export const wallDefinition: NodeDefinition = { }, floorplanMoveTarget: wallFloorplanMoveTarget, floorplanSiblingOverrides: wallFloorplanSiblingOverrides, - toolHints: [ { key: 'Left click', label: 'Set wall start / end' }, { key: 'Esc', label: 'Cancel' }, diff --git a/packages/nodes/src/wall/index.ts b/packages/nodes/src/wall/index.ts index b30febab09..07e7c94572 100644 --- a/packages/nodes/src/wall/index.ts +++ b/packages/nodes/src/wall/index.ts @@ -1,2 +1,3 @@ +export { createConicalRoofSectorAboveWall } from './conical-roof' export { wallDefinition } from './definition' export { WallNode } from './schema' 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..1f6fd12da7 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) @@ -114,6 +190,128 @@ describe('roof system shed geometry', () => { geometry.dispose() }) + test('does not emit vertical fascia along a connected shed footprint edge', () => { + const parent = RoofNode.parse({ + id: 'roof_connected_shed', + type: 'roof', + children: ['rseg_connected_a', 'rseg_connected_b'], + }) + const base = RoofSegmentNode.parse({ + id: 'rseg_connected_a', + type: 'roof-segment', + parentId: parent.id, + roofType: 'shed', + width: 2, + depth: 2, + wallHeight: 0, + wallThickness: 0.01, + pitch: 15, + overhang: 0, + deckThickness: 0.1, + shingleThickness: 0.025, + wallShell: 'omit', + shedFootprintPieces: [ + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + ], + }) + const sibling = RoofSegmentNode.parse({ + ...base, + id: 'rseg_connected_b', + position: [2, 0, 0], + }) + const nodes = { + [parent.id]: parent, + [base.id]: base, + [sibling.id]: sibling, + } + const geometry = generateRoofSegmentGeometry(base, nodes) + const position = geometry.getAttribute('position') + const index = geometry.getIndex()! + const normal = new THREE.Vector3() + let verticalTriangles = 0 + for (const group of geometry.groups) { + for (let offset = group.start; offset < group.start + group.count; offset += 3) { + const a = new THREE.Vector3().fromBufferAttribute(position, index.getX(offset)) + const b = new THREE.Vector3().fromBufferAttribute(position, index.getX(offset + 1)) + const c = new THREE.Vector3().fromBufferAttribute(position, index.getX(offset + 2)) + normal.subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).normalize() + if (Math.abs(normal.y) < 1e-6) verticalTriangles += 1 + } + } + + expect(verticalTriangles).toBe(6) + geometry.dispose() + }) + + test('omits the vertical cut face along a managed diagonal shed seam', () => { + const parent = RoofNode.parse({ + id: 'roof_connected_diagonal_shed', + type: 'roof', + children: ['rseg_diagonal_a', 'rseg_diagonal_b'], + }) + const base = RoofSegmentNode.parse({ + id: 'rseg_diagonal_a', + type: 'roof-segment', + parentId: parent.id, + roofType: 'shed', + width: 2, + depth: 2, + wallHeight: 0, + wallThickness: 0.01, + pitch: 15, + overhang: 0, + deckThickness: 0.1, + shingleThickness: 0.025, + wallShell: 'omit', + managedByParent: true, + trim: { frontRightX: 1, frontRightZ: 1 }, + }) + const sibling = RoofSegmentNode.parse({ + ...base, + id: 'rseg_diagonal_b', + managedByParent: false, + trim: {}, + shedFootprintPieces: [ + [ + [1, 0], + [1, 1], + [0, 1], + ], + ], + }) + const nodes = { + [parent.id]: parent, + [base.id]: base, + [sibling.id]: sibling, + } + const geometry = generateRoofSegmentGeometry(base, nodes) + const position = geometry.getAttribute('position') + const index = geometry.getIndex()! + const normal = new THREE.Vector3() + let diagonalVerticalTriangles = 0 + + for (const group of geometry.groups) { + for (let offset = group.start; offset < group.start + group.count; offset += 3) { + const a = new THREE.Vector3().fromBufferAttribute(position, index.getX(offset)) + const b = new THREE.Vector3().fromBufferAttribute(position, index.getX(offset + 1)) + const c = new THREE.Vector3().fromBufferAttribute(position, index.getX(offset + 2)) + normal.subVectors(b, a).cross(new THREE.Vector3().subVectors(c, a)).normalize() + if (Math.abs(normal.y) > 1e-6) continue + if ([a, b, c].every((point) => Math.abs(point.x + point.z - 1) < 1e-6)) { + diagonalVerticalTriangles += 1 + } + } + } + + expect(diagonalVerticalTriangles).toBe(0) + geometry.dispose() + }) + test('bends a curved shed deck into a thin concentric band (no balloon)', () => { const depth = 2 // Arc chosen so the back (wall) edge lands at radius 5 and the front edge @@ -248,3 +446,150 @@ describe('roof system shed geometry', () => { geometry.dispose() }) }) + +describe('roof system conical sector geometry', () => { + test('does not leave broad radial closure triangles on a narrow sector', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_narrow_conical_sector', + type: 'roof-segment', + roofType: 'conical', + width: 2, + depth: 2, + wallHeight: 0, + pitch: 40, + overhang: 0.3, + conicalStartAngle: 0.3, + conicalSweepAngle: 0.5, + }) + + const geometry = generateRoofSegmentGeometry(segment) + const position = geometry.getAttribute('position') + const index = geometry.getIndex() + expect(index).not.toBeNull() + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + const normal = new THREE.Vector3() + let broadCutTriangleCount = 0 + for (let offset = 0; offset < index!.count; offset += 3) { + a.fromBufferAttribute(position, index!.getX(offset)) + b.fromBufferAttribute(position, index!.getX(offset + 1)) + c.fromBufferAttribute(position, index!.getX(offset + 2)) + normal.crossVectors(ab.subVectors(b, a), ac.subVectors(c, a)) + const area = normal.length() / 2 + normal.normalize() + const radii = [a, b, c].map((point) => Math.hypot(point.x, point.z)) + const ys = [a.y, b.y, c.y] + if ( + Math.abs(normal.y) < 0.05 && + area > 0.1 && + Math.min(...radii) < 0.1 && + Math.max(...radii) > 0.5 && + Math.max(...ys) - Math.min(...ys) > 0.5 + ) { + broadCutTriangleCount += 1 + } + } + + let whiteSlopeArea = 0 + for (const group of geometry.groups) { + if (group.materialIndex !== 0) continue + for (let offset = group.start; offset < group.start + group.count; offset += 3) { + a.fromBufferAttribute(position, index!.getX(offset)) + b.fromBufferAttribute(position, index!.getX(offset + 1)) + c.fromBufferAttribute(position, index!.getX(offset + 2)) + normal.crossVectors(ab.subVectors(b, a), ac.subVectors(c, a)) + const area = normal.length() / 2 + normal.normalize() + if (normal.y > 0.1) whiteSlopeArea += area + } + } + + expect(broadCutTriangleCount).toBe(0) + expect(whiteSlopeArea).toBeLessThan(0.05) + geometry.dispose() + }) + + test('keeps large sectors free of CSG striping and phantom wall faces', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_large_conical_sector', + type: 'roof-segment', + roofType: 'conical', + width: 10, + depth: 10, + wallHeight: 0, + pitch: 40, + overhang: 0.3, + conicalStartAngle: 0.3, + conicalSweepAngle: 1, + }) + + const geometry = generateRoofSegmentGeometry(segment) + const triangleCount = (geometry.getIndex()?.count ?? 0) / 3 + + expect(triangleCount).toBeLessThan(100) + expect(geometry.groups.some((group) => group.materialIndex === 2)).toBe(false) + geometry.dispose() + }) + + test('emits canopy wall faces with both windings', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_double_sided_conical_walls', + type: 'roof-segment', + roofType: 'conical', + width: 4, + depth: 4, + wallHeight: 2, + wallThickness: 0.1, + pitch: 40, + overhang: 0.3, + conicalStartAngle: 0.3, + conicalSweepAngle: 1, + }) + + const geometry = generateRoofSegmentGeometry(segment) + const position = geometry.getAttribute('position') + const index = geometry.getIndex() + expect(index).not.toBeNull() + + const wallMaterialIndices = new Set() + const windingCounts = new Map() + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const normal = new THREE.Vector3() + for (const group of geometry.groups) { + for (let offset = group.start; offset < group.start + group.count; offset += 3) { + a.fromBufferAttribute(position, index!.getX(offset)) + b.fromBufferAttribute(position, index!.getX(offset + 1)) + c.fromBufferAttribute(position, index!.getX(offset + 2)) + const ys = [a.y, b.y, c.y] + if (Math.min(...ys) > 0.001 || Math.max(...ys) < segment.wallHeight - 0.001) continue + wallMaterialIndices.add(group.materialIndex ?? 0) + normal.crossVectors(b.clone().sub(a), c.clone().sub(a)).normalize() + const firstNonzero = [normal.x, normal.y, normal.z].find( + (coordinate) => Math.abs(coordinate) > 1e-5, + ) + const winding = firstNonzero !== undefined && firstNonzero < 0 ? 1 : 0 + if (winding === 1) normal.negate() + const signature = [normal.x, normal.y, normal.z, normal.dot(a)] + .map((coordinate) => coordinate.toFixed(4)) + .join(',') + const counts = windingCounts.get(signature) ?? [0, 0] + counts[winding] += 1 + windingCounts.set(signature, counts) + } + } + + expect([...wallMaterialIndices]).toEqual([0]) + expect(windingCounts.size).toBeGreaterThan(0) + expect( + [...windingCounts.values()].every( + ([forwardCount, reverseCount]) => forwardCount > 0 && forwardCount === reverseCount, + ), + ).toBe(true) + geometry.dispose() + }) +}) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 88577f7b95..670779ebcb 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + getConicalRoofCoverage, getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getEffectiveNode, @@ -14,6 +15,7 @@ import { isBandedShedSegment, nodeRegistry, normalizeRoofSegmentTrim, + pointInPolygon2D, ROOF_SHAPE_DEFAULTS, type RoofNode, type RoofPlanBounds, @@ -22,6 +24,7 @@ import { roofOverlapEntryOwns, roofPlanBoundsOverlap, sceneRegistry, + unionPolygons, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -584,7 +587,7 @@ function updateMergedRoofGeometry( child.position, child.rotation ?? 0, ), - () => buildCustomShedGeometry(child), + () => buildCustomShedGeometry(child, nodes) ?? buildDirectConicalSectorGeometry(child), ) if (directGeometry) { let withPanels = addShedInsetEndPanels(directGeometry, [child], false) @@ -636,7 +639,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 +1039,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 ( @@ -1336,6 +1350,7 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe overhang, shingleThickness, } = node + const conicalCoverage = getConicalRoofCoverage(node) const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node) const shapeRatios = getRoofShapeRatios({ @@ -1393,6 +1408,8 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe tanTheta, shapeRatios, dutchTopRakeThickness: node.dutchTopRakeThickness, + conicalStartAngle: conicalCoverage.startAngle, + conicalSweepAngle: conicalCoverage.sweepAngle, }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) return createGeometryFromFaces(faces, materialRule ?? matIndex) } @@ -1430,7 +1447,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)) { @@ -1495,6 +1512,8 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe tanTheta, shapeRatios, dutchTopRakeThickness: node.dutchTopRakeThickness, + conicalStartAngle: conicalCoverage.startAngle, + conicalSweepAngle: conicalCoverage.sweepAngle, }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) const topFaces = getRoofModuleFaces({ type: roofType, @@ -1509,6 +1528,8 @@ export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSe tanTheta, shapeRatios, dutchTopRakeThickness: node.dutchTopRakeThickness, + conicalStartAngle: conicalCoverage.startAngle, + conicalSweepAngle: conicalCoverage.sweepAngle, }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) let rakeBoards: THREE.BufferGeometry | null = null @@ -1641,24 +1662,22 @@ 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, node.position, node.rotation ?? 0, ) - const directShedGeometry = withSegmentUvMatrix(segmentWorldMatrix, () => - buildCustomShedGeometry(node), + const directSegmentGeometry = withSegmentUvMatrix( + segmentWorldMatrix, + () => buildCustomShedGeometry(node, nodes) ?? buildDirectConicalSectorGeometry(node), ) - if (directShedGeometry) { - let result = addShedInsetEndPanels(directShedGeometry, [node], false) + if (directSegmentGeometry) { + let result = addShedInsetEndPanels(directSegmentGeometry, [node], false) if (nodes) { result = clipDirectRoofGeometryAgainstSiblings(result, node, nodes, 'segment') } @@ -1684,7 +1703,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 +1832,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 +1872,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, @@ -2089,6 +2120,164 @@ function buildConcentricBandDeckGeometry(node: RoofSegmentNode): THREE.BufferGeo return merged } +type ConicalLayerProfile = { + eaveY: number + peakY: number + radius: number +} + +function buildDirectConicalSectorGeometry(node: RoofSegmentNode): THREE.BufferGeometry | null { + if (node.roofType !== 'conical' || hasSegmentTrim(node)) return null + const coverage = getConicalRoofCoverage(node) + if (coverage.fullCircle) return null + + const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node) + const deckRadius = node.width / 2 + node.wallThickness / 2 + node.overhang * Math.max(0, cosTheta) + const deckDrop = (deckRadius - node.width / 2) * tanTheta + const deckVerticalThickness = node.deckThickness / Math.max(0.1, cosTheta) + const shingleRadialThickness = node.shingleThickness * sinTheta + const shingleVerticalThickness = node.shingleThickness * cosTheta + const deckBottom: ConicalLayerProfile = { + radius: deckRadius, + eaveY: node.wallHeight - deckDrop, + peakY: node.wallHeight + activeRh, + } + const deckTop: ConicalLayerProfile = { + radius: deckRadius, + eaveY: deckBottom.eaveY + deckVerticalThickness, + peakY: deckBottom.peakY + deckVerticalThickness, + } + const shingleTop: ConicalLayerProfile = { + radius: deckRadius + shingleRadialThickness, + eaveY: deckTop.eaveY + shingleVerticalThickness, + peakY: deckTop.peakY + shingleVerticalThickness + shingleRadialThickness * tanTheta, + } + const radialSegments = Math.max( + 1, + Math.ceil((48 * Math.abs(coverage.sweepAngle)) / (Math.PI * 2)), + ) + const angles = Array.from( + { length: radialSegments + 1 }, + (_, index) => coverage.startAngle + (index / radialSegments) * coverage.sweepAngle, + ) + const ring = (profile: ConicalLayerProfile) => + angles.map( + (angle) => + new THREE.Vector3( + Math.cos(angle) * profile.radius, + profile.eaveY, + Math.sin(angle) * profile.radius, + ), + ) + const deckBottomRing = ring(deckBottom) + const deckTopRing = ring(deckTop) + const shingleTopRing = ring(shingleTop) + const deckBottomApex = new THREE.Vector3(0, deckBottom.peakY, 0) + const deckTopApex = new THREE.Vector3(0, deckTop.peakY, 0) + const shingleTopApex = new THREE.Vector3(0, shingleTop.peakY, 0) + + const orient = (face: THREE.Vector3[], upward: boolean) => { + const normalY = new THREE.Vector3() + .subVectors(face[1]!, face[0]!) + .cross(new THREE.Vector3().subVectors(face[2]!, face[0]!)).y + return normalY >= 0 === upward ? face : [...face].reverse() + } + const layerFaces = ( + bottomRing: THREE.Vector3[], + bottomApex: THREE.Vector3, + topRing: THREE.Vector3[], + topApex: THREE.Vector3, + ) => { + const bottomFaces: THREE.Vector3[][] = [] + const topFaces: THREE.Vector3[][] = [] + const edgeFaces: THREE.Vector3[][] = [] + for (let index = 0; index < radialSegments; index += 1) { + const next = index + 1 + bottomFaces.push( + orient( + [bottomRing[index]!, bottomRing[next]!, bottomApex].map((point) => point.clone()), + false, + ), + ) + topFaces.push( + orient( + [topRing[index]!, topRing[next]!, topApex].map((point) => point.clone()), + true, + ), + ) + edgeFaces.push([ + bottomRing[index]!.clone(), + bottomRing[next]!.clone(), + topRing[next]!.clone(), + topRing[index]!.clone(), + ]) + } + const last = radialSegments + edgeFaces.push( + [bottomApex.clone(), bottomRing[0]!.clone(), topRing[0]!.clone(), topApex.clone()], + [bottomApex.clone(), topApex.clone(), topRing[last]!.clone(), bottomRing[last]!.clone()], + ) + return { bottomFaces, topFaces, edgeFaces } + } + + const deck = layerFaces(deckBottomRing, deckBottomApex, deckTopRing, deckTopApex) + const shingles = layerFaces(deckTopRing, deckTopApex, shingleTopRing, shingleTopApex) + const geometries = [ + createGeometryFromFaces([...deck.bottomFaces, ...deck.edgeFaces], ROOF_EDGE_MATERIAL_INDEX), + createGeometryFromFaces(shingles.topFaces, 3), + createGeometryFromFaces(shingles.edgeFaces, ROOF_EDGE_MATERIAL_INDEX), + ] + + if (node.wallHeight > 0.001) { + const outerRadius = node.width / 2 + node.wallThickness / 2 + const innerRadius = Math.max(0.005, node.width / 2 - node.wallThickness / 2) + const outerBottom = angles.map( + (angle) => new THREE.Vector3(Math.cos(angle) * outerRadius, 0, Math.sin(angle) * outerRadius), + ) + const outerTop = outerBottom.map( + (point) => new THREE.Vector3(point.x, node.wallHeight, point.z), + ) + const innerBottom = angles.map( + (angle) => new THREE.Vector3(Math.cos(angle) * innerRadius, 0, Math.sin(angle) * innerRadius), + ) + const innerTop = innerBottom.map( + (point) => new THREE.Vector3(point.x, node.wallHeight, point.z), + ) + const wallFaces: THREE.Vector3[][] = [] + for (let index = 0; index < radialSegments; index += 1) { + const next = index + 1 + pushDoubleSidedFace(wallFaces, [ + outerBottom[next]!, + outerBottom[index]!, + outerTop[index]!, + outerTop[next]!, + ]) + pushDoubleSidedFace(wallFaces, [ + innerBottom[index]!, + innerBottom[next]!, + innerTop[next]!, + innerTop[index]!, + ]) + } + const last = radialSegments + pushDoubleSidedFace(wallFaces, [outerBottom[0]!, innerBottom[0]!, innerTop[0]!, outerTop[0]!]) + pushDoubleSidedFace(wallFaces, [ + innerBottom[last]!, + outerBottom[last]!, + outerTop[last]!, + innerTop[last]!, + ]) + geometries.push(createGeometryFromFaces(wallFaces, ROOF_EDGE_MATERIAL_INDEX)) + } + + const merged = mergeGeometriesPreservingGroups(geometries) + for (const geometry of geometries) geometry.dispose() + if (!merged) return null + merged.computeVertexNormals() + ensureRenderableGeometryAttributes(merged) + return merged +} + function clipRoofPolygonAtX( polygon: readonly [number, number][], boundaryX: number, @@ -2108,6 +2297,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,9 +2347,450 @@ function facetBandedRoofPieces( return faceted } -function buildCustomShedGeometry(node: RoofSegmentNode): THREE.BufferGeometry | null { +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 transformShedPlanPoint( + node: RoofSegmentNode, + point: readonly [number, number], + nodes?: Record, +): [number, number] { + const rotation = node.rotation ?? 0 + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + const segmentPoint: [number, number] = [ + (node.position[0] ?? 0) + point[0] * cos + point[1] * sin, + (node.position[2] ?? 0) - point[0] * sin + point[1] * cos, + ] + const ownerId = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record).managedByLeanTo + : undefined + const owner = typeof ownerId === 'string' ? nodes?.[ownerId] : undefined + if (owner?.type !== 'lean-to-extension') return segmentPoint + const ownerRotation = owner.rotation[1] ?? 0 + const ownerCos = Math.cos(ownerRotation) + const ownerSin = Math.sin(ownerRotation) + return [ + (owner.position[0] ?? 0) + segmentPoint[0] * ownerCos + segmentPoint[1] * ownerSin, + (owner.position[2] ?? 0) - segmentPoint[0] * ownerSin + segmentPoint[1] * ownerCos, + ] +} + +function inverseTransformShedPlanPoint( + node: RoofSegmentNode, + point: readonly [number, number], + nodes?: Record, +): [number, number] { + const ownerId = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record).managedByLeanTo + : undefined + const owner = typeof ownerId === 'string' ? nodes?.[ownerId] : undefined + let source = point + if (owner?.type === 'lean-to-extension') { + const ownerRotation = owner.rotation[1] ?? 0 + const ownerCos = Math.cos(ownerRotation) + const ownerSin = Math.sin(ownerRotation) + const dx = point[0] - (owner.position[0] ?? 0) + const dz = point[1] - (owner.position[2] ?? 0) + source = [dx * ownerCos - dz * ownerSin, dx * ownerSin + dz * ownerCos] + } + const rotation = node.rotation ?? 0 + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + const dx = source[0] - (node.position[0] ?? 0) + const dz = source[1] - (node.position[2] ?? 0) + return [dx * cos - dz * sin, dx * sin + dz * cos] +} + +function edgeTouchesSiblingShed( + node: RoofSegmentNode, + start: readonly [number, number], + end: readonly [number, number], + nodes: Record | undefined, +): boolean { + if (!nodes) return false + const metadata = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record).managedByLeanTo + : undefined + const ownerId = typeof metadata === 'string' ? metadata : undefined + const scopeId = (segment: RoofSegmentNode): string | undefined => { + const segmentOwnerId = + segment.metadata && typeof segment.metadata === 'object' && !Array.isArray(segment.metadata) + ? (segment.metadata as Record).managedByLeanTo + : undefined + const owner = typeof segmentOwnerId === 'string' ? nodes[segmentOwnerId] : undefined + let parentId: string | undefined = owner?.parentId ?? segment.parentId ?? undefined + const parent = parentId ? nodes[parentId] : undefined + if (parent?.type === 'wall') parentId = parent.parentId ?? undefined + return parentId + } + const nodeScope = scopeId(node) + const siblings = Object.values(nodes).filter( + (candidate): candidate is RoofSegmentNode => + candidate.type === 'roof-segment' && + candidate.id !== node.id && + (Boolean(node.parentId && candidate.parentId === node.parentId) || + (typeof ownerId === 'string' && + nodeScope !== undefined && + scopeId(candidate) === nodeScope && + Boolean( + candidate.metadata && + typeof candidate.metadata === 'object' && + !Array.isArray(candidate.metadata) && + typeof (candidate.metadata as Record).managedByLeanTo === 'string', + ))) && + candidate.roofType === 'shed', + ) + if (siblings.length === 0) return false + + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length <= 1e-6) return false + const nx = -dz / length + const nz = dx / length + for (const ratio of [0.25, 0.5, 0.75]) { + const x = start[0] + dx * ratio + const z = start[1] + dz * ratio + const world = transformShedPlanPoint(node, [x, z], nodes) + const samples: [number, number][] = [ + world, + transformShedPlanPoint(node, [x + nx * 1e-4, z + nz * 1e-4], nodes), + transformShedPlanPoint(node, [x - nx * 1e-4, z - nz * 1e-4], nodes), + ] + for (const sample of samples) { + if ( + siblings.some((sibling) => { + const local = inverseTransformShedPlanPoint(sibling, sample, nodes) + const footprints = readShedFootprintPieces(sibling) + const polygons = + footprints.length > 0 + ? footprints + : sibling.managedByParent + ? [managedShedFootprint(sibling)] + : [] + return polygons.some((polygon) => + pointInPolygon2D(local, polygon, { includeBoundary: false }), + ) + }) + ) { + return true + } + } + } + return false +} + +function findSiblingShedAcrossEdge( + node: RoofSegmentNode, + start: readonly [number, number], + end: readonly [number, number], + nodes: Record | undefined, +): RoofSegmentNode | undefined { + if (!nodes) return undefined + const ownerId = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record).managedByLeanTo + : undefined + const scopeId = (segment: RoofSegmentNode): string | undefined => { + const segmentOwnerId = + segment.metadata && typeof segment.metadata === 'object' && !Array.isArray(segment.metadata) + ? (segment.metadata as Record).managedByLeanTo + : undefined + const owner = typeof segmentOwnerId === 'string' ? nodes[segmentOwnerId] : undefined + let parentId: string | undefined = owner?.parentId ?? segment.parentId ?? undefined + const parent = parentId ? nodes[parentId] : undefined + if (parent?.type === 'wall') parentId = parent.parentId ?? undefined + return parentId + } + const nodeScope = scopeId(node) + const siblings = Object.values(nodes).filter( + (candidate): candidate is RoofSegmentNode => + candidate.type === 'roof-segment' && + candidate.id !== node.id && + (Boolean(node.parentId && candidate.parentId === node.parentId) || + (typeof ownerId === 'string' && + nodeScope !== undefined && + scopeId(candidate) === nodeScope && + Boolean( + candidate.metadata && + typeof candidate.metadata === 'object' && + !Array.isArray(candidate.metadata) && + typeof (candidate.metadata as Record).managedByLeanTo === 'string', + ))) && + candidate.roofType === 'shed', + ) + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length <= 1e-6) return undefined + const nx = -dz / length + const nz = dx / length + return siblings.find((sibling) => + [0.25, 0.5, 0.75].every((ratio) => { + const x = start[0] + dx * ratio + const z = start[1] + dz * ratio + const samples: [number, number][] = [ + transformShedPlanPoint(node, [x, z], nodes), + transformShedPlanPoint(node, [x + nx * 1e-4, z + nz * 1e-4], nodes), + transformShedPlanPoint(node, [x - nx * 1e-4, z - nz * 1e-4], nodes), + ] + return samples.some((sample) => { + const local = inverseTransformShedPlanPoint(sibling, sample, nodes) + const footprints = readShedFootprintPieces(sibling) + const polygons = + footprints.length > 0 + ? footprints + : sibling.managedByParent + ? [managedShedFootprint(sibling)] + : [] + return polygons.some((polygon) => + pointInPolygon2D(local, polygon, { includeBoundary: false }), + ) + }) + }), + ) +} + +function shedWorldYOrigin(node: RoofSegmentNode, nodes: Record): number { + const ownerId = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record).managedByLeanTo + : undefined + const owner = typeof ownerId === 'string' ? nodes[ownerId] : undefined + if (owner?.type === 'lean-to-extension') { + return (owner.position[1] ?? 0) + (node.position[1] ?? 0) + } + const parent = node.parentId ? nodes[node.parentId] : undefined + return (parent?.type === 'roof' ? (parent.position[1] ?? 0) : 0) + (node.position[1] ?? 0) +} + +function shedVerticalThickness(node: RoofSegmentNode): number { + const { cosTheta } = getSegmentSlopeFrame(node) + return node.deckThickness / Math.max(0.1, cosTheta) + node.shingleThickness * cosTheta +} + +function buildShedJointTransitionFaces( + node: RoofSegmentNode, + sibling: RoofSegmentNode, + start: readonly [number, number], + end: readonly [number, number], + nodes: Record, + verticalThickness: number, +): THREE.Vector3[][] { + if (String(node.id) > String(sibling.id)) return [] + + const nodeWorldY = shedWorldYOrigin(node, nodes) + const siblingWorldY = shedWorldYOrigin(sibling, nodes) + const siblingThickness = shedVerticalThickness(sibling) + const heights = (point: readonly [number, number]) => { + const ownTop = getRoofSegmentSurfaceY(node, point[0], point[1]) + verticalThickness + const worldPoint = transformShedPlanPoint(node, point, nodes) + const siblingPoint = inverseTransformShedPlanPoint(sibling, worldPoint, nodes) + const siblingTop = + siblingWorldY + + getRoofSegmentSurfaceY(sibling, siblingPoint[0], siblingPoint[1]) + + siblingThickness - + nodeWorldY + return { ownTop, siblingTop } + } + const startHeights = heights(start) + const endHeights = heights(end) + const startDelta = startHeights.siblingTop - startHeights.ownTop + const endDelta = endHeights.siblingTop - endHeights.ownTop + const tolerance = 1e-5 + if (Math.abs(startDelta) <= tolerance && Math.abs(endDelta) <= tolerance) return [] + + const face = ( + firstPoint: readonly [number, number], + firstHeights: { ownTop: number; siblingTop: number }, + secondPoint: readonly [number, number], + secondHeights: { ownTop: number; siblingTop: number }, + ) => { + const firstOwn = new THREE.Vector3(firstPoint[0], firstHeights.ownTop, firstPoint[1]) + const secondOwn = new THREE.Vector3(secondPoint[0], secondHeights.ownTop, secondPoint[1]) + const secondSibling = new THREE.Vector3( + secondPoint[0], + secondHeights.siblingTop, + secondPoint[1], + ) + const firstSibling = new THREE.Vector3(firstPoint[0], firstHeights.siblingTop, firstPoint[1]) + if (Math.abs(firstHeights.siblingTop - firstHeights.ownTop) <= tolerance) { + return [firstOwn, secondOwn, secondSibling] + } + if (Math.abs(secondHeights.siblingTop - secondHeights.ownTop) <= tolerance) { + return [firstOwn, secondOwn, firstSibling] + } + return [firstOwn, secondOwn, secondSibling, firstSibling] + } + const faces: THREE.Vector3[][] = [] + if (startDelta * endDelta < -tolerance * tolerance) { + const ratio = startDelta / (startDelta - endDelta) + const crossingPoint: [number, number] = [ + start[0] + (end[0] - start[0]) * ratio, + start[1] + (end[1] - start[1]) * ratio, + ] + const crossingHeights = heights(crossingPoint) + pushDoubleSidedFace(faces, face(start, startHeights, crossingPoint, crossingHeights)) + pushDoubleSidedFace(faces, face(crossingPoint, crossingHeights, end, endHeights)) + } else { + pushDoubleSidedFace(faces, face(start, startHeights, end, endHeights)) + } + return faces +} + +function clipShedPolygonByLine( + polygon: RoofPlanPolygon, + lineStart: readonly [number, number], + lineEnd: readonly [number, number], + outsidePoint: readonly [number, number], +): RoofPlanPolygon { + const lineDx = lineEnd[0] - lineStart[0] + const lineDz = lineEnd[1] - lineStart[1] + const outsideSide = + Math.sign( + lineDx * (outsidePoint[1] - lineStart[1]) - lineDz * (outsidePoint[0] - lineStart[0]), + ) || 1 + const side = (point: readonly [number, number]) => + outsideSide * (lineDx * (point[1] - lineStart[1]) - lineDz * (point[0] - lineStart[0])) + const clipped: RoofPlanPolygon = [] + for (let index = 0; index < polygon.length; index++) { + const current = polygon[index]! + const next = polygon[(index + 1) % polygon.length]! + const currentSide = side(current) + const nextSide = side(next) + const currentInside = currentSide <= 1e-7 + const nextInside = nextSide <= 1e-7 + if (currentInside) clipped.push([current[0], current[1]]) + if (currentInside !== nextInside) { + const ratio = currentSide / (currentSide - nextSide) + clipped.push([ + current[0] + (next[0] - current[0]) * ratio, + current[1] + (next[1] - current[1]) * ratio, + ]) + } + } + return clipped +} + +function managedShedFootprint(node: RoofSegmentNode): RoofPlanPolygon { + const trim = normalizeRoofSegmentTrim(node) + let polygon: RoofPlanPolygon = [ + [-node.width / 2 + trim.left, -node.depth / 2 + trim.back], + [node.width / 2 - trim.right, -node.depth / 2 + trim.back], + [node.width / 2 - trim.right, node.depth / 2 - trim.front], + [-node.width / 2 + trim.left, node.depth / 2 - trim.front], + ] + const diagonalTrims = [ + { + x: trim.frontLeftX, + z: trim.frontLeftZ, + start: [-node.width / 2 + trim.left + trim.frontLeftX, node.depth / 2 - trim.front] as [ + number, + number, + ], + end: [-node.width / 2 + trim.left, node.depth / 2 - trim.front - trim.frontLeftZ] as [ + number, + number, + ], + outside: [-node.width / 2 - 1, node.depth / 2 + 1] as [number, number], + }, + { + x: trim.frontRightX, + z: trim.frontRightZ, + start: [node.width / 2 - trim.right, node.depth / 2 - trim.front - trim.frontRightZ] as [ + number, + number, + ], + end: [node.width / 2 - trim.right - trim.frontRightX, node.depth / 2 - trim.front] as [ + number, + number, + ], + outside: [node.width / 2 + 1, node.depth / 2 + 1] as [number, number], + }, + { + x: trim.backLeftX, + z: trim.backLeftZ, + start: [-node.width / 2 + trim.left, -node.depth / 2 + trim.back + trim.backLeftZ] as [ + number, + number, + ], + end: [-node.width / 2 + trim.left + trim.backLeftX, -node.depth / 2 + trim.back] as [ + number, + number, + ], + outside: [-node.width / 2 - 1, -node.depth / 2 - 1] as [number, number], + }, + { + x: trim.backRightX, + z: trim.backRightZ, + start: [node.width / 2 - trim.right - trim.backRightX, -node.depth / 2 + trim.back] as [ + number, + number, + ], + end: [node.width / 2 - trim.right, -node.depth / 2 + trim.back + trim.backRightZ] as [ + number, + number, + ], + outside: [node.width / 2 + 1, -node.depth / 2 - 1] as [number, number], + }, + ] + for (const diagonal of diagonalTrims) { + if (diagonal.x <= 0 || diagonal.z <= 0 || polygon.length < 3) continue + polygon = clipShedPolygonByLine(polygon, diagonal.start, diagonal.end, diagonal.outside) + } + return polygon +} + +function buildCustomShedGeometry( + node: RoofSegmentNode, + nodes?: Record, +): THREE.BufferGeometry | null { if (node.roofType !== 'shed') return null - const pieces = readShedFootprintPieces(node) + const storedPieces = readShedFootprintPieces(node) + const pieces = + storedPieces.length > 0 + ? storedPieces + : node.managedByParent + ? [managedShedFootprint(node)] + : [] const banded = isBandedShedSegment(node) && node.arc if (pieces.length === 0) return banded ? buildConcentricBandDeckGeometry(node) : null const renderPieces = banded @@ -2146,33 +2803,97 @@ 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 jointTransitionFaces: THREE.Vector3[][] = [] + 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 + const sideFace = [ + 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 touchesSibling = edgeTouchesSiblingShed(node, outline[index]!, outline[next]!, nodes) + if (!touchesSibling) { + faces.push(sideFace) + continue + } + const sibling = findSiblingShedAcrossEdge(node, outline[index]!, outline[next]!, nodes) + if (sibling && nodes) { + jointTransitionFaces.push( + ...buildShedJointTransitionFaces( + node, + sibling, + outline[index]!, + outline[next]!, + nodes, + verticalThickness, + ), + ) + } + } } geometries.push( createGeometryFromFaces(faces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : ROOF_EDGE_MATERIAL_INDEX, ), ) + if (jointTransitionFaces.length > 0) { + geometries.push(createGeometryFromFaces(jointTransitionFaces, 3)) + } + } + + 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 @@ -2863,7 +3584,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 +3838,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 +3869,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.