diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 5ba049ddbe..8c6007ddfd 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,6 +1,11 @@ 'use client' -import { nodeRegistry } from '@pascal-app/core' +import { + nodeRegistry, + type RoofType, + RoofType as RoofTypeSchema, + useRegistryVersion, +} from '@pascal-app/core' import { type FloorplanMode, getFloorplanNodeExtension, @@ -13,13 +18,20 @@ import { } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' -import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/toolbar-tooltip' +import { + getActiveRoofFeatureId, + getRoofFootprintSource, + getRoofFootprintSources, + ROOF_TYPE_OPTIONS, + type RoofFootprintSource, +} from '@/lib/build-tab-state' import { cn } from '@/lib/utils' /** @@ -169,10 +181,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 +219,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 +262,17 @@ 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 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 +295,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 +303,16 @@ 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 isTypeActive = (type: BuildType) => { if (type.mode) return mode === type.mode @@ -364,54 +402,124 @@ export function BuildTab() {
- ) : mode === 'build' && - (activeTool === 'roof' || isRoofFeatureActive) && - roofFeatures.length > 0 ? ( -
-
- Features & extensions -
- -
- {roofFeatures.map((feature) => { - const active = mode === 'build' && activeTool === feature.kind + ) : mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive) ? ( +
+
+
Roof type
+
+ {ROOF_TYPE_OPTIONS.map((roofType) => { + const active = activeTool === 'roof' && activeRoofType === roofType.value return ( - - - - - - {feature.label} - - + ) })}
- +
+ + {activeRoofType !== 'conical' && ( +
+
Create from
+
+ {footprintSources.map((source) => { + const active = activeTool === 'roof' && activeFootprintSource === source.value + return ( + + ) + })} +
+

+ {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.' + : '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..c5d45d6a5a --- /dev/null +++ b/apps/editor/lib/build-tab-state.test.ts @@ -0,0 +1,54 @@ +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 only curved-wall footprint source', () => { + expect(getRoofFootprintSources('conical').map((source) => source.value)).toEqual(['walls']) + expect(getRoofFootprintSource('conical', 'room')).toBe('walls') + expect(getRoofFootprintSource('conical', 'draw')).toBe('walls') +}) + +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..5bd8a18092 --- /dev/null +++ b/apps/editor/lib/build-tab-state.ts @@ -0,0 +1,51 @@ +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]] 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..ffffc785e3 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 { createConicalRoofSectorAboveWall } from './lib/conical-roof' +export { + type ConicalRoofInvalidPlacement, + type ConicalRoofLevelPlacement, + type ConicalRoofPlacement, + type ConicalRoofSurfacePlacement, + type ResolveConicalRoofPlacementInput, + 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..211c1d2a76 --- /dev/null +++ b/packages/core/src/lib/conical-roof-placement.test.ts @@ -0,0 +1,172 @@ +// @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, + allowRoofSupport: false, + requireRoofSupport: false, + }) + + 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, + allowRoofSupport: true, + requireRoofSupport: false, + }) + + 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('auto mode does not mount a circle through the missing half of a conical sector', () => { + const { level, roof, nodes } = sceneWithHost() + const sector = RoofSegmentNode.parse({ + id: 'rseg_host', + parentId: roof.id, + roofType: 'conical', + width: 10, + depth: 10, + wallHeight: 0, + pitch: 45, + conicalStartAngle: 0, + conicalSweepAngle: Math.PI / 2, + conicalFullCircle: false, + }) + const sectorNodes = { ...nodes, [sector.id]: sector } + + const placement = resolveConicalRoofPlacement({ + nodes: sectorNodes, + levelId: level.id, + center: [2, 3], + radius: 1, + curbHeight: 0.5, + allowRoofSupport: true, + requireRoofSupport: false, + }) + + expect(placement.valid).toBe(true) + expect(placement.support).toEqual({ kind: 'level' }) + }) + + 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, + allowRoofSupport: true, + requireRoofSupport: true, + }) + + 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, + allowRoofSupport: true, + requireRoofSupport: false, + }) + + 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..aa630eb5d3 --- /dev/null +++ b/packages/core/src/lib/conical-roof-placement.ts @@ -0,0 +1,222 @@ +import type { AnyNode, LevelNode, RoofNode, RoofSegmentNode, RoofSupport } from '../schema' +import { getRoofSegmentSurfaceY } from '../schema' + +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 + allowRoofSupport: boolean + requireRoofSupport: boolean +} + +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') { + if (Math.hypot(point[0], point[1]) > segment.width / 2 + FOOTPRINT_EPSILON) return false + if (segment.conicalFullCircle) return true + const start = segment.conicalStartAngle ?? 0 + const sweep = segment.conicalSweepAngle ?? Math.PI * 2 + if (Math.abs(sweep) >= Math.PI * 2 - FOOTPRINT_EPSILON) return true + const angle = Math.atan2(point[1], point[0]) + const directedDelta = (from: number, to: number) => { + const delta = (to - from) % (Math.PI * 2) + return delta < 0 ? delta + Math.PI * 2 : delta + } + return sweep >= 0 + ? directedDelta(start, angle) <= sweep + FOOTPRINT_EPSILON + : directedDelta(angle, start) <= -sweep + 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, + allowRoofSupport, + requireRoofSupport, +}: ResolveConicalRoofPlacementInput): ConicalRoofPlacement { + if (!allowRoofSupport) return levelPlacement(center, curbHeight) + + const candidate = findRoofCandidate(nodes, levelId, center, Math.max(0, radius)) + if (!candidate) { + return requireRoofSupport + ? { 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/conical-roof.ts b/packages/core/src/lib/conical-roof.ts new file mode 100644 index 0000000000..e2bcbcc154 --- /dev/null +++ b/packages/core/src/lib/conical-roof.ts @@ -0,0 +1,88 @@ +import { + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, +} from '../hooks/spatial-grid/spatial-grid-manager' +import { resolveLevelId } from '../hooks/spatial-grid/spatial-grid-sync' +import type { SceneApi } from '../registry/types' +import { + type AnyNode, + type AnyNodeId, + type LevelNode, + RoofNode, + RoofSegmentNode, + type WallNode, +} from '../schema' +import { getLevelBelow, getLevelElevations } from '../services/storey' +import { getWallArcData } from '../systems/wall/wall-curve' + +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 existingRoof = Object.values(nodes).find( + (node): node is RoofNode => + node.type === 'roof' && + node.parentId === targetLevelId && + typeof node.metadata === 'object' && + node.metadata !== null && + !Array.isArray(node.metadata) && + (node.metadata as Record).conicalSourceWallId === wall.id, + ) + if (existingRoof) { + const existingSegment = existingRoof.children + .map((childId) => nodes[childId]) + .find((node): node is RoofSegmentNode => node?.type === 'roof-segment') + if (existingSegment) return existingSegment.id + } + + 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}`, + metadata: { conicalSourceWallId: wall.id }, + 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/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..4a6feb333d 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,111 @@ 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') + }) + + test('does not recreate an intentionally empty dormer window list', () => { + useScene.getState().setScene( + { + dormer_test: { + object: 'node', + id: 'dormer_test', + type: 'dormer', + parentId: null, + visible: true, + metadata: {}, + children: [], + }, + } as unknown as Record, + ['dormer_test'] as never, + ) + + const dormer = useScene.getState().nodes.dormer_test as Extract + expect(dormer.children).toEqual([]) + }) }) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index cd5f23a772..58ddcef9c2 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,40 @@ 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 hasLegacyInlineWindow = !Array.isArray( + (patchedNodes[id] as { children?: unknown }).children, + ) + if (!hasLegacyInlineWindow) continue + 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/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 0c0c9db318..ba4895689a 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1658,7 +1658,13 @@ export const SelectionManager = () => { const hasModifier = nativeEvent.shiftKey || isCommandModifier(nativeEvent) const isAlreadySole = selectedIdsBeforeRouting.length === 1 && selectedIdsBeforeRouting[0] === nodeToSelect.id - if (!hasModifier && isAlreadySole && !getMovingNode() && canDirectMoveNode(nodeToSelect)) { + if ( + useEditor.getState().mode !== 'delete' && + !hasModifier && + isAlreadySole && + !getMovingNode() && + canDirectMoveNode(nodeToSelect) + ) { sfxEmitter.emit('sfx:item-pick') useEditor.getState().setMovingNode(nodeToSelect as never) useViewer.getState().setSelection({ selectedIds: [] }) 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..aeae6eb08a --- /dev/null +++ b/packages/editor/src/components/tools/roof/roof-footprint.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from 'bun:test' +import { emitter, LevelNode, type WallEvent, WallNode } from '@pascal-app/core' +import { + fitRoofFootprint, + isStandardRoofWallEligible, + parseRoofFootprintSource, + resolveRoofFootprintElevation, + resolveRoofFootprintWorldElevation, + 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('walls') + 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('only treats axis-aligned straight walls as standard-roof draw guides', () => { + expect(isStandardRoofWallEligible(WallNode.parse({ start: [0, 0], end: [4, 0] }))).toBe(true) + expect(isStandardRoofWallEligible(WallNode.parse({ start: [0, 0], end: [0, 4] }))).toBe(true) + expect(isStandardRoofWallEligible(WallNode.parse({ start: [0, 0], end: [4, 3] }))).toBe(false) + expect( + isStandardRoofWallEligible(WallNode.parse({ start: [-2, 0], end: [2, 0], curveOffset: 2 })), + ).toBe(false) + }) + + test('keeps an L-shaped room available as straight-wall draw guides but not a room footprint', () => { + const target = fitRoofFootprint( + 'room-l-shape', + [ + [0, 0], + [4, 0], + [4, 2], + [2, 2], + [2, 4], + [0, 4], + ], + [], + ) + + expect(target?.rectangular).toBe(false) + }) + + test('rejects curved and irregular rooms for rectangular-only roof footprints', () => { + const walls = [ + WallNode.parse({ start: [0, 0], end: [4, 0] }), + WallNode.parse({ start: [4, 0], end: [4, 2] }), + WallNode.parse({ start: [4, 2], end: [2, 3] }), + WallNode.parse({ start: [2, 3], end: [0, 2] }), + WallNode.parse({ start: [0, 2], end: [0, 0] }), + ] + const level = LevelNode.parse({ children: walls.map((wall) => wall.id) }) + const nodes = Object.fromEntries([level, ...walls].map((node) => [node.id, node])) + + expect(resolveRoomRoofFootprint(level.id, nodes, [2, 1], { rectangularOnly: true })).toBeNull() + }) + + 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) + expect(target && resolveRoofFootprintWorldElevation(activeLevel.id, target, nodes)).toBe(3) + }) + + 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, + getNodes: () => 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, + getNodes: () => 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([]) + }) + + test('ignores straight walls for conical roof hover and selection', () => { + const wall = WallNode.parse({ + parentId: 'level_active', + start: [0, 0], + end: [4, 0], + }) + const level = LevelNode.parse({ id: 'level_active', children: [wall.id], level: 0 }) + const nodes = Object.fromEntries([level, wall].map((node) => [node.id, node])) + const previewed: Array = [] + const selected: string[] = [] + const unsubscribe = subscribeToConicalRoofWallClicks({ + footprintSource: 'walls', + currentLevelId: level.id, + getNodes: () => 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..7ae0138134 --- /dev/null +++ b/packages/editor/src/components/tools/roof/roof-footprint.ts @@ -0,0 +1,278 @@ +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 +} + +const ROOF_AXIS_ALIGNMENT_EPSILON = 1e-4 + +export function isStandardRoofWallEligible(wall: WallNode): boolean { + if (isCurvedWall(wall)) return false + const deltaX = Math.abs(wall.end[0] - wall.start[0]) + const deltaZ = Math.abs(wall.end[1] - wall.start[1]) + return deltaX <= ROOF_AXIS_ALIGNMENT_EPSILON || deltaZ <= ROOF_AXIS_ALIGNMENT_EPSILON +} + +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 (roofType === 'conical') return 'walls' + return value === 'draw' ? 'draw' : 'room' +} + +export function subscribeToConicalRoofWallClicks(options: { + footprintSource: RoofFootprintSource + currentLevelId: LevelNode['id'] | null + getNodes: () => 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.getNodes()) + ? 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.getNodes()) + ) { + 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], + options: { rectangularOnly?: boolean } = {}, +): RoofFootprintTarget | null { + const activeTarget = resolveRoomRoofFootprintOnLevel(levelId, nodes, point) + if (activeTarget && (!options.rectangularOnly || activeTarget.rectangular)) return activeTarget + if (activeTarget) return null + const levelBelow = getLevelBelow(levelId, nodes as Record) + const levelBelowTarget = levelBelow + ? resolveRoomRoofFootprintOnLevel(levelBelow.id, nodes, point) + : null + return levelBelowTarget && (!options.rectangularOnly || levelBelowTarget.rectangular) + ? levelBelowTarget + : 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 resolveRoofFootprintWorldElevation( + targetLevelId: LevelNode['id'], + target: RoofFootprintTarget, + nodes: Readonly>, +): number { + const completeNodes = nodes as Record + const elevations = getLevelElevations(completeNodes) + return ( + (elevations.get(targetLevelId)?.baseY ?? 0) + + resolveRoofFootprintElevation(targetLevelId, target, nodes) + ) +} + +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..ba59905d31 --- /dev/null +++ b/packages/editor/src/components/tools/roof/roof-placement-mode.ts @@ -0,0 +1,20 @@ +import { create } from 'zustand' + +export type RoofPlacementMode = 'auto' | 'ground' | 'roof' + +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..23075f503c 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -3,19 +3,29 @@ import { type AnyNode, type AnyNodeId, collectAlignmentAnchors, + createConicalRoofSectorAboveWall, + createSceneApi, emitter, type GridEvent, + getWallArcData, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, + isCurvedWall, type LevelNode, RoofNode, RoofSegmentNode, + type RoofType, + RoofType as RoofTypeSchema, resolveBuildingForLevel, + resolveConicalRoofPlacement, sceneRegistry, useScene, + type WallEvent, 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,17 +43,40 @@ 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 { + isStandardRoofWallEligible, + parseRoofFootprintSource, + type RoofFootprintTarget, + resolveRoofFootprintElevation, + resolveRoofFootprintWorldElevation, + resolveRoofWallTopWorldElevation, + resolveRoomRoofFootprint, + subscribeToConicalRoofWallClicks, +} from './roof-footprint' +import useRoofPlacementMode, { type RoofPlacementMode } from './roof-placement-mode' const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_PITCH_DEG = 40 const GRID_OFFSET = 0.02 +function placementOptions(mode: RoofPlacementMode) { + return { + allowRoofSupport: mode !== 'ground', + requireRoofSupport: mode === 'roof', + } +} + function resolveRoofDraftPlacement( footprintWidth: number, 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, @@ -94,21 +127,39 @@ function getBelowLevelWalls( function getRoofSnapWalls( currentLevelId: string | null, nodes: Readonly>, + roofType: RoofType, ): WallNode[] { - return [...getLevelWalls(currentLevelId, nodes), ...getBelowLevelWalls(currentLevelId, nodes)] + const walls = [ + ...getLevelWalls(currentLevelId, nodes), + ...getBelowLevelWalls(currentLevelId, nodes), + ] + return roofType === 'conical' + ? walls.filter((wall) => isCurvedWall(wall)) + : walls.filter(isStandardRoofWallEligible) } // Current-level alignment anchors plus the floor-below wall corners. function collectRoofAlignmentAnchors( nodes: Readonly>, currentLevelId: string | null, + roofType: RoofType, ): AlignmentAnchor[] { - return [ + const anchors = [ ...collectAlignmentAnchors(nodes, '', currentLevelId), ...getBelowLevelWalls(currentLevelId, nodes).flatMap((wall) => wallSegmentAnchors(wall.id, wall.start, wall.end, wall.thickness), ), ] + if (roofType === 'conical') { + return anchors.filter((anchor) => { + const node = nodes[anchor.nodeId] + return node?.type !== 'wall' || isCurvedWall(node) + }) + } + return anchors.filter((anchor) => { + const node = nodes[anchor.nodeId] + return node?.type !== 'wall' || isStandardRoofWallEligible(node) + }) } /** @@ -120,7 +171,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 +181,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 +190,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, + ...placementOptions(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 +269,7 @@ const commitRoofPlacement = ( footprintDepth, quarterTurn, targetRoof.rotation, + roofType, ) const segment = RoofSegmentNode.parse({ @@ -201,6 +297,7 @@ const commitRoofPlacement = ( footprintDepth, quarterTurn, roofRotation, + roofType, ) // Create the segment first (centered in its new parent) @@ -234,6 +331,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({ + pitch: DEFAULT_PITCH_DEG, + roofType: 'gable', + ...defaults, + wallHeight: 0, + 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 +383,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 +391,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 +470,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 +563,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.getState().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 +588,22 @@ 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 [invalidStandardWallHover, setInvalidStandardWallHover] = useState(false) 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 @@ -432,14 +614,18 @@ export const RoofTool: React.FC = () => { // level plus the wall corners of the floor directly below, so a roof drawn // on the upper floor aligns to the walls beneath it. Refreshed after each // roof commits. Both corners of the rectangle align. - let alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId) + let alignmentCandidates = collectRoofAlignmentAnchors( + useScene.getState().nodes, + currentLevelId, + roofType, + ) // Resolve a grid:move/click into the drafted corner via the shared surface // snap pipeline: magnetic lock onto wall corners / midpoints / crossings / // bodies on the active level + floor below (raising the green beacon), // falling back to alignment guides, then to the world-grid snap. The same // path the slab/ceiling tools use, so the beacon and coloring match. The - // pipeline reads the snapping mode itself (Shift bypass, magnetic on/off), + // 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] => { @@ -455,26 +641,77 @@ export const RoofTool: React.FC = () => { return resolveSurfacePlanPointSnap({ rawPoint, fallbackPoint: gridFallback, - walls: getRoofSnapWalls(currentLevelId, nodes), + walls: getRoofSnapWalls(currentLevelId, nodes, roofType), candidates: alignmentCandidates, movingId: '__roof-draft__', highlightWalls: true, }).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, + ...placementOptions(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 +721,34 @@ export const RoofTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return + if (footprintSource !== 'draw') { + const [snappedX, snappedZ] = resolveDraftPoint(event) + let target: RoofFootprintTarget | null = null + if (footprintSource === 'room') { + target = resolveRoomRoofFootprint( + currentLevelId, + useScene.getState().nodes, + [snappedX, snappedZ], + { + rectangularOnly: true, + }, + ) + updateFootprintPreview(target) + } + cursorRef.current.position.set( + snappedX, + target + ? resolveRoofFootprintWorldElevation( + currentLevelId, + target, + useScene.getState().nodes, + ) + GRID_OFFSET + : event.localPosition[1] + GRID_OFFSET, + snappedZ, + ) + return + } + const [gridX, gridZ] = resolveDraftPoint(event) const y = event.localPosition[1] @@ -520,6 +785,21 @@ export const RoofTool: React.FC = () => { const onGridClick = (event: GridEvent) => { if (!currentLevelId) return + if (footprintSource !== 'draw') { + if (footprintSource !== 'room') return + const [snappedX, snappedZ] = resolveDraftPoint(event) + const target = resolveRoomRoofFootprint( + currentLevelId, + useScene.getState().nodes, + [snappedX, snappedZ], + { rectangularOnly: true }, + ) + 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 +810,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 @@ -539,7 +822,11 @@ export const RoofTool: React.FC = () => { draftPreview.setRoofDraftStart(null) draftPreview.setRoofDraftEnd(null) outlineRef.current.visible = false - alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId) + alignmentCandidates = collectRoofAlignmentAnchors( + useScene.getState().nodes, + currentLevelId, + roofType, + ) clearSurfacePlanSnapFeedback() } else { corner1Ref.current = [gridX, y, gridZ] @@ -565,6 +852,8 @@ export const RoofTool: React.FC = () => { setPreview((prev) => ({ ...prev, corner1: null })) } clearSurfacePlanSnapFeedback() + previewTargetIdRef.current = null + setPreviewSelectedIds([]) } const onKeyDown = (event: KeyboardEvent) => { @@ -575,6 +864,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 +899,54 @@ export const RoofTool: React.FC = () => { emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) + const onWallHover = (event: WallEvent) => { + setInvalidStandardWallHover( + footprintSource === 'draw' && + roofType !== 'conical' && + !isStandardRoofWallEligible(event.node), + ) + } + const onWallLeave = () => setInvalidStandardWallHover(false) + emitter.on('wall:enter', onWallHover) + emitter.on('wall:move', onWallHover) + emitter.on('wall:leave', onWallLeave) + const unsubscribeConicalRoofWallClicks = subscribeToConicalRoofWallClicks({ + footprintSource, + currentLevelId, + getNodes: () => useScene.getState().nodes, + onPreview: (wall) => { + setPreviewedConicalWallId(wall?.id ?? null) + setPreviewSelectedIds(wall ? [wall.id] : []) + }, + onSelect: (wall) => { + setPreviewedConicalWallId(null) + setPreviewSelectedIds([]) + const segmentId = createConicalRoofSectorAboveWall( + wall, + useScene.getState().nodes, + createSceneApi(useScene), + currentLevelId as LevelNode['id'], + ) + if (segmentId) setSelection({ selectedIds: [segmentId] }) + }, + roofType, + }) window.addEventListener('keydown', onKeyDown) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + emitter.off('wall:enter', onWallHover) + emitter.off('wall:move', onWallHover) + emitter.off('wall:leave', onWallLeave) + unsubscribeConicalRoofWallClicks() window.removeEventListener('keydown', onKeyDown) clearSurfacePlanSnapFeedback() + previewTargetIdRef.current = null + setPreviewedConicalWallId(null) + setInvalidStandardWallHover(false) + setPreviewSelectedIds([]) corner1Ref.current = null const draftPreview = useFloorplanDraftPreview.getState() @@ -611,7 +954,7 @@ export const RoofTool: React.FC = () => { draftPreview.setRoofDraftEnd(null) draftPreview.setRoofDraftQuarterTurn(false) } - }, [currentLevelId, setSelection]) + }, [currentLevelId, footprintSource, roofType, setPreviewSelectedIds, setSelection]) const { corner1, cursorPosition, levelY } = preview @@ -624,35 +967,152 @@ 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, + ...placementOptions(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 + ? resolveRoofFootprintWorldElevation(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 ( + invalidStandardWallHover || + !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, + invalidStandardWallHover, + previewPitch, + quarterTurn, + resolvedPreviewDimensions, + roofType, + ]) const roofGhostEdges = useMemo(() => { - if (!previewDimensions) return null + if ( + invalidStandardWallHover || + !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, + invalidStandardWallHover, + previewPitch, + quarterTurn, + resolvedPreviewDimensions, + roofType, + ]) + + useEffect(() => { + if (invalidStandardWallHover) outlineRef.current.visible = false + }, [invalidStandardWallHover]) useEffect( () => () => { @@ -662,6 +1122,14 @@ export const RoofTool: React.FC = () => { [roofGhostEdges, roofGhostGeometry], ) + useEffect( + () => () => { + conicalWallGhost?.geometry.dispose() + conicalWallGhost?.edges.dispose() + }, + [conicalWallGhost], + ) + return ( @@ -694,37 +1162,80 @@ export const RoofTool: React.FC = () => { /> )} - {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && ( + {conicalWallGhost && ( - {roofGhostGeometry && ( - - - - )} - {roofGhostEdges && ( - - - - )} + + + + + + )} + + {!invalidStandardWallHover && + 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..82ed693149 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,45 @@ 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 R and T to a moving lean-to extension', () => { + const leanTo = { id: 'lean_to_moving', type: 'lean-to-extension' } as unknown as AnyNode + useInteractionScope.getState().begin({ + kind: 'moving', + node: leanTo, + nodeId: leanTo.id, + nodeType: leanTo.type, + view: '3d', + }) + + 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..16000dd43d 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -171,6 +171,31 @@ 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' || + moving?.type === 'lean-to-extension' + ) + 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 +215,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 +378,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 +511,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 984cab3e1b..0d105fdc12 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -366,6 +366,7 @@ export { type ElevationGuideSource, type ElevationSnapMatch, type ElevationSnapTarget, + publishResolvedElevationGuide, publishStructuralElevationGuide, resolveElevationSnapMatch, resolveStructuralElevationSnap, @@ -426,7 +427,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 8b9622bd8b..0c3ace670b 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 '../shared/lean-to-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 219fbcec87..bd5cc438b2 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 f11ee87442..fa9a214865 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,89 +25,19 @@ 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: 1000, 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', + label: 'Hung wall', 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, - }, + { key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 1000, 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/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..e2cfaa5ad6 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,22 @@ import { RoofSegmentNode, type RoofSegmentNode as RoofSegmentNodeType, spatialGridManager, + terrainFieldOf, type WallNode, } from '@pascal-app/core' import { resolveEaveSnap } from '../gutter/eave-snap' +import { isLeanToPostOmitted } from '../shared/lean-to-post-omissions' 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 +42,7 @@ import { leanToCornerJointMetadata, resolveLeanToCornerJoints, } from './corner-joint' -import { resolveLeanToLayout } from './layout' +import { isDualSlopeLeanToCanopy, resolveLeanToLayout } from './layout' const MANAGED_BY_KEY = 'managedByLeanTo' const MANAGED_ROLE_KEY = 'leanToRole' @@ -39,6 +52,9 @@ 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 SHED_JOINT_NEIGHBORS_KEY = 'leanToShedJointNeighbors' const POST_GUTTER_CLEARANCE = 0.02 const POST_GROUND_EMBED = 0.02 const POST_BEAM_EMBED = 0.02 @@ -53,6 +69,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 +131,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 +171,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 +229,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 +289,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 +346,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 +373,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 +389,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 +436,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 +470,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,13 +519,61 @@ export type LeanToRoofSegmentLayoutPatch = Pick< | 'shedSideInfillMaxX' | 'shedFootprintPieces' | 'shedOpenEndSides' + | 'managedByParent' + | 'wallShell' + | 'shedInsetEndPanels' | 'trim' | 'metadata' > +function joinedNeighborLeanToIds( + leanTo: LeanToExtensionNode, + nodes?: Record, +): string[] { + const wall = + leanTo.parentId && nodes?.[leanTo.parentId]?.type === 'wall' + ? (nodes[leanTo.parentId] as WallNode) + : undefined + const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) + const linearCanopyJoints = Object.entries(resolveFreestandingCanopyJoints(leanTo, nodes)).filter( + ([side, joint]) => joint?.kind === 'linear' && !cornerJoints[side as LeanToCornerSide], + ) + const ids = [ + ...Object.values(cornerJoints).flatMap((joint) => + joint?.neighborId ? [joint.neighborId] : [], + ), + ...linearCanopyJoints.flatMap(([, joint]) => (joint?.neighborId ? [joint.neighborId] : [])), + ] + return [...new Set(ids)] +} + +/** + * Number of freestanding runs in the connected chain of joined lean-tos that + * `leanTo` belongs to (itself included). A plain run is 1, a single-corner L is + * 2, a J is 3, a closed square is 4. Corner mitering is only applied to a pure + * L (chain of 2); longer chains render as plain overlapping runs. + */ +function joinedRunChainSize(leanTo: LeanToExtensionNode, nodes?: Record): number { + if (!nodes) return 1 + const seen = new Set([leanTo.id]) + const queue: LeanToExtensionNode[] = [leanTo] + while (queue.length > 0) { + const current = queue.shift()! + for (const id of joinedNeighborLeanToIds(current, nodes)) { + if (seen.has(id)) continue + const neighbor = nodes[id] + if (neighbor?.type !== 'lean-to-extension') continue + seen.add(id) + queue.push(neighbor) + } + } + return seen.size +} + export function leanToRoofSegmentLayoutPatch( leanTo: LeanToExtensionNode, nodes?: Record, + plane: LeanToRoofPlane = 'primary', ): LeanToRoofSegmentLayoutPatch { const layout = resolveLeanToLayout(leanTo) const wall = @@ -377,10 +582,77 @@ 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> + // For straight freestanding mono runs, corner mitering is only applied to a + // single-corner L (a joined chain of two runs). J-shapes, longer chains, and + // closed loops render as plain overlapping runs — no footprint shaping, no + // joint step closures, no corner extension. Curved and wall-attached canopies + // keep their multi-corner mitering. + const isStraightFreestandingMono = leanTo.hostKind === 'freestanding' && !isCurvedLeanTo(leanTo) + const miterAcrossCorner = !isStraightFreestandingMono || joinedRunChainSize(leanTo, nodes) <= 2 + const segmentExtension = (joint: LeanToCornerJoint | undefined) => + !miterAcrossCorner || (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 +682,14 @@ 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 allJoints = [...Object.values(cornerJoints), ...Object.values(linearCanopyJoints)] + const jointSides = allJoints.flatMap((joint) => (joint ? [joint.side] : [])) + const jointNeighborIds = [ + ...new Set(allJoints.flatMap((joint) => (joint?.neighborId ? [joint.neighborId] : []))), + ] + 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 +726,20 @@ export function leanToRoofSegmentLayoutPatch( shedSideInfillSpan: layout.span, shedSideInfillMinX: -layout.span / 2 - sideMemberFaceInset - roofCenterX, shedSideInfillMaxX: layout.span / 2 + sideMemberFaceInset - roofCenterX, - shedFootprintPieces: jointSides.length > 0 ? roofPieces : undefined, - shedOpenEndSides: jointSides.length > 0 ? jointSides : undefined, - metadata: managedMetadata(leanTo, 'roof-segment'), + shedFootprintPieces: hasShapedCorner && miterAcrossCorner ? roofPieces : undefined, + shedOpenEndSides: miterAcrossCorner && jointSides.length > 0 ? jointSides : undefined, + managedByParent: true, + wallShell: 'omit', + shedInsetEndPanels: true, + metadata: managedMetadata(leanTo, 'roof-segment', { + [ROOF_PLANE_KEY]: plane, + ...(miterAcrossCorner && jointNeighborIds.length > 0 + ? { [SHED_JOINT_NEIGHBORS_KEY]: jointNeighborIds } + : {}), + }), 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 +763,7 @@ export function leanToGutterLayoutPatch( leanTo: LeanToExtensionNode, gutter?: GutterNodeType, nodes?: Record, + drainageSide: LeanToDrainageSide = 'primary', ): Pick< GutterNodeType, | 'position' @@ -486,17 +774,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 +838,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 +903,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 +911,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 +963,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 +1022,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 +1033,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 +1070,7 @@ export function createLeanToAssembly( extension: LeanToExtensionNode roof: RoofNodeType segment: RoofSegmentNodeType + oppositeSegment?: RoofSegmentNodeType gutter: GutterNodeType downspout: DownspoutNodeType posts: ColumnNodeType[] @@ -698,24 +1082,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 +1126,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 bcdd0f2648..9c11bf239e 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: 1000, 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..2a88cd9fc8 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 { isLeanToPostOmitted } from '../shared/lean-to-post-omissions' import { bendLocalPoint, isCurvedLeanTo } from './arc' +import { resolveFreestandingCanopyJoints } from './canopy-joint' import { leanToFacetCount } from './geometry' -import { resolveLeanToLayout } from './layout' +import { isDualSlopeLeanToCanopy, resolveLeanToLayout } from './layout' + +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..a0dda28559 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/mono-j-rendering.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LevelNode } from '@pascal-app/core' +import { generateRoofSegmentGeometry } from '@pascal-app/viewer' +import { Matrix4, Mesh, Quaternion, Raycaster, Vector3 } from 'three' +import { createLeanToAssembly } from './assembly' +import { resolveLeanToFreestandingRunPlacement } from './placement' + +type Pt = readonly [number, number] +const YUP = new Vector3(0, 1, 0) + +// Corner mitering is only applied to a single-corner L (a joined chain of two +// runs). J-shapes, longer chains, and closed loops intentionally render as plain +// overlapping runs: no shaped footprint pieces and no joint step closures. +describe('continuous mono canopy rendering', () => { + // Node ids are random (nanoid), so the L miter must not depend on id order. + // Each case is built repeatedly; every build must agree and cover its single + // corner cleanly (no black-wedge holes, no stepped overlaps). + const lShapes: Record = { + 'L forward': [ + [0, 0], + [8, 0], + [8, 4], + ], + 'L reversed': [ + [8, 4], + [8, 0], + [0, 0], + ], + 'L rotated': [ + [0, 0], + [5.66, 5.66], + [2.83, 8.49], + ], + } + + for (const [name, points] of Object.entries(lShapes)) { + test(`${name}: deterministic miter with clean coverage`, () => { + const builds = Array.from({ length: 8 }, (_, index) => + buildCanopy(`${name}_${index}`, points), + ) + const verticalAreaKeys = new Set(builds.map((build) => build.totalVertical.toFixed(4))) + expect(verticalAreaKeys.size).toBe(1) + for (const build of builds) { + expect(build.holes).toBe(0) + expect(build.overlaps).toBe(0) + // Both runs of an L are mitered: shaped footprint + a single joined side. + for (const segment of build.segments) { + expect(shedFootprintPieceCount(segment)).toBeGreaterThan(0) + expect(openEndSideCount(segment)).toBe(1) + } + } + }) + } + + // A J-shape (and longer chains / closed loops) must NOT be mitered: every run + // renders as a plain rectangle with no shaped footprint and no joined sides. + const unmiteredShapes: Record = { + 'J three runs': [ + [0, 0], + [8, 0], + [8, 4], + [3, 4], + ], + 'J reversed': [ + [3, 4], + [8, 4], + [8, 0], + [0, 0], + ], + 'J different lengths': [ + [0, 0], + [12, 0], + [12, 3], + [5, 3], + ], + 'square closed': [ + [0, 0], + [8, 0], + [8, 8], + [0, 8], + [0, 0], + ], + } + + for (const [name, points] of Object.entries(unmiteredShapes)) { + test(`${name}: renders as plain un-mitered runs`, () => { + const { segments } = buildCanopy(name, points) + // No shaped footprint and no joined sides: each run is a plain rectangle. + for (const segment of segments) { + expect(shedFootprintPieceCount(segment)).toBe(0) + expect(openEndSideCount(segment)).toBe(0) + } + }) + } +}) + +function shedFootprintPieceCount(segment: ReturnType['segment']) { + const pieces = (segment as Record).shedFootprintPieces + return Array.isArray(pieces) ? pieces.length : 0 +} + +function openEndSideCount(segment: ReturnType['segment']) { + const sides = (segment as Record).shedOpenEndSides + return Array.isArray(sides) ? sides.length : 0 +} + +function worldMatrix(assembly: ReturnType) { + const roof = assembly.roof + const seg = assembly.segment + const roofM = new Matrix4().compose( + new Vector3(...(roof.position as number[])), + new Quaternion().setFromAxisAngle(YUP, (roof.rotation as number) ?? 0), + new Vector3(1, 1, 1), + ) + const segM = new Matrix4().compose( + new Vector3(...(seg.position as number[])), + new Quaternion().setFromAxisAngle(YUP, seg.rotation ?? 0), + new Vector3(1, 1, 1), + ) + return roofM.multiply(segM) +} + +// Build a continuous mono canopy from a poly-line and measure the rendered roof +// segments: the total vertical roof-finish (material 3) area used to close +// internal miter steps, plus a top-down coverage scan for holes/overlaps. +function buildCanopy(name: string, points: readonly Pt[]) { + const level = LevelNode.parse({ id: `level_${name}`, level: 0 }) + 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< + string, + AnyNode + > + const assemblies = runs.map((run) => createLeanToAssembly(run, undefined, sourceNodes)) + const renderNodes = Object.fromEntries( + [level, ...runs, ...assemblies.flatMap((a) => [a.roof, a.segment])].map((n) => [n.id, n]), + ) as Record + + const worldGeoms = [] + const perSegVertical: number[] = [] + const a = new Vector3() + const b = new Vector3() + const c = new Vector3() + const normal = new Vector3() + for (const assembly of assemblies) { + const geometry = generateRoofSegmentGeometry(assembly.segment, renderNodes) + let segVertical = 0 + const position = geometry.getAttribute('position') + const index = geometry.getIndex()! + for (const group of geometry.groups) { + if (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(b.clone().sub(a), c.clone().sub(a)) + const area = normal.length() / 2 + normal.normalize() + if (Math.abs(normal.y) <= 0.05) segVertical += area + } + } + perSegVertical.push(segVertical) + geometry.applyMatrix4(worldMatrix(assembly)) + worldGeoms.push(geometry) + } + + const coverage = sampleTopCoverage(worldGeoms) + for (const geometry of worldGeoms) geometry.dispose() + const totalVertical = perSegVertical.reduce((sum, value) => sum + value, 0) + return { perSegVertical, totalVertical, segments: assemblies.map((a) => a.segment), ...coverage } +} + +// Cast rays straight down over the union footprint. A covered interior column +// should hit exactly one upward-facing (material 3) surface: zero means a +// hole/black wedge, two separated hits means overlapping planes. +function sampleTopCoverage(worldGeoms: ReturnType[]) { + const meshes = worldGeoms.map((geometry) => new Mesh(geometry)) + const raycaster = new Raycaster() + raycaster.firstHitOnly = false + const box = { minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity, maxY: -Infinity } + for (const geometry of worldGeoms) { + geometry.computeBoundingBox() + const bounds = geometry.boundingBox! + box.minX = Math.min(box.minX, bounds.min.x) + box.maxX = Math.max(box.maxX, bounds.max.x) + box.minZ = Math.min(box.minZ, bounds.min.z) + box.maxZ = Math.max(box.maxZ, bounds.max.z) + box.maxY = Math.max(box.maxY, bounds.max.y) + } + const step = 0.15 + const inset = 0.35 + const direction = new Vector3(0, -1, 0) + const origin = new Vector3() + let holes = 0 + let overlaps = 0 + for (let x = box.minX + inset; x <= box.maxX - inset; x += step) { + for (let z = box.minZ + inset; z <= box.maxZ - inset; z += step) { + origin.set(x, box.maxY + 5, z) + raycaster.set(origin, direction) + let anyHit = false + const topYs: number[] = [] + for (const mesh of meshes) { + const hits = raycaster.intersectObject(mesh, false) + if (hits.length > 0) anyHit = true + for (const hit of hits) { + if ((hit.face?.normal.y ?? 0) > 0.2) topYs.push(hit.point.y) + } + } + if (!anyHit) continue + if (topYs.length === 0) holes += 1 + else if (topYs.length >= 2) { + topYs.sort((first, second) => first - second) + if (topYs[topYs.length - 1]! - topYs[0]! > 0.02) overlaps += 1 + } + } + } + return { holes, overlaps } +} 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..0bb2bce774 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/multi-joint-mono.test.ts @@ -0,0 +1,126 @@ +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) + }) + + // A J-shape (chain of 3+ freestanding straight mono runs) is not mitered: each + // run renders as a plain rectangle with no shaped footprint pieces. + test('leaves every run un-mitered 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 footprintsByRun = runs.map( + (run) => createLeanToAssembly(run, undefined, nodes).segment.shedFootprintPieces ?? [], + ) + + expect(footprintsByRun.every((footprints) => footprints.length === 0)).toBe(true) + }) + + test('leaves every run un-mitered 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) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/parametrics.ts b/packages/nodes/src/lean-to-extension/parametrics.ts index cc28550c14..f0824b6e93 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..364bb773ae --- /dev/null +++ b/packages/nodes/src/lean-to-extension/post-omissions.test.ts @@ -0,0 +1,54 @@ +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 '../shared/lean-to-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/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..6665d2bfbb 100644 --- a/packages/nodes/src/lean-to-extension/system.tsx +++ b/packages/nodes/src/lean-to-extension/system.tsx @@ -13,14 +13,21 @@ import type { WallNode, } from '@pascal-app/core' import { useEffect } from 'react' +import { isLeanToPostOmitted } from '../shared/lean-to-post-omissions' 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 +35,38 @@ 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 { 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 559b520b35..2b148dd743 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' && ( + + + )} + + + {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({ 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} - /> - - - { - 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/lean-to-post-omissions.ts b/packages/nodes/src/shared/lean-to-post-omissions.ts new file mode 100644 index 0000000000..e2c2a4f03d --- /dev/null +++ b/packages/nodes/src/shared/lean-to-post-omissions.ts @@ -0,0 +1,58 @@ +import type { AnyNode, AnyNodeId, ColumnNode, LeanToExtensionNode } from '@pascal-app/core' +import type { LeanToPostSide } from '../lean-to-extension/assembly' +import { resolveLeanToLayout } from '../lean-to-extension/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/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/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index bca76db924..e7bc4bba92 100644 --- a/packages/nodes/src/wall/definition.test.ts +++ b/packages/nodes/src/wall/definition.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + createConicalRoofSectorAboveWall, + RoofNode, + RoofSegmentNode, + type SceneApi, +} from '@pascal-app/core' import { getFloorplanNodeExtension } from '@pascal-app/editor' import { wallDefinition } from './definition' @@ -57,3 +64,215 @@ 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 reuses its existing hosted roof', () => { + const level = { + object: 'node', + id: 'level_test', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: ['wall_test', 'roof_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 segment = RoofSegmentNode.parse({ + id: 'rseg_test', + parentId: 'roof_test', + roofType: 'conical', + }) + const roof = RoofNode.parse({ + id: 'roof_test', + parentId: level.id, + metadata: { conicalSourceWallId: wall.id }, + children: [segment.id], + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [roof.id]: roof, + [segment.id]: segment, + } as Record + const created: AnyNode[] = [] + const sceneApi = { + createMany: (ops) => created.push(...ops.map((op) => op.node)), + nodes: () => nodes, + } as SceneApi + + expect(createConicalRoofSectorAboveWall(wall, nodes, sceneApi, level.id as AnyNodeId)).toBe( + segment.id, + ) + expect(created).toHaveLength(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/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..2c3bd09973 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, @@ -428,22 +445,24 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.clampedX) // Keep the REAL node hidden and show a tinted ghost in the wall opening — // green when placeable, red when it collides — matching the free-follow - // 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) { - useScene.getState().updateNode(movingWindowNode.id, { - position: [target.clampedX, target.clampedY, 0], - rotation: [0, target.itemRotation, 0], - side: target.side, - parentId: target.wallId, - wallId: target.wallId, - roofSegmentId: undefined, - roofFace: undefined, - visible: false, - }) + // ghost so validity reads at a glance (see MoveDoorTool). The live + // override keeps the preview data-driven without mutating the scene. + const hostChanged = currentHostId !== target.wallId + if (hostChanged) { markHostDirty(currentHostId) currentHostId = target.wallId - } else { + } + useLiveNodeOverrides.getState().set(movingWindowNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, + roofSegmentId: undefined, + roofFace: undefined, + visible: false, + }) + if (!hostChanged) { const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId) if (windowMesh) { windowMesh.position.set(target.clampedX, target.clampedY, 0) @@ -609,6 +628,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 +660,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode triggerSFX('sfx:structure-build') hideCursor() - useViewer.getState().setSelection({ selectedIds: [placedId] }) + selectNode(placedId as AnyNodeId) exitMoveMode() } @@ -682,12 +703,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode setGhostPose(null) useFacingPose.getState().clear() clearPlacementSurface() - const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as - | WindowNode - | undefined - if (live && live.visible === false) { - useScene.getState().updateNode(movingWindowNode.id, { visible: true }) - } + useLiveNodeOverrides.getState().set(movingWindowNode.id, { visible: true }) } // Free-follow: over open floor there's no wall to host the window, so hide @@ -697,6 +713,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 +722,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() @@ -711,25 +730,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const yaw = sideOverride === 'back' ? Math.PI : 0 if (currentHostId !== levelId) { if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId) - useScene.getState().updateNode(movingWindowNode.id, { - position: [localX, sillCenterY, localZ], - rotation: [0, yaw, 0], - side: sideOverride, - parentId: levelId ?? undefined, - wallId: undefined, - roofSegmentId: undefined, - roofFace: undefined, - visible: false, - }) currentHostId = levelId - } else { - useScene.getState().updateNode(movingWindowNode.id, { - position: [localX, sillCenterY, localZ], - rotation: [0, yaw, 0], - side: sideOverride, - visible: false, - }) } + useLiveNodeOverrides.getState().set(movingWindowNode.id, { + position: [localX, sillCenterY, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + parentId: levelId ?? undefined, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + visible: false, + }) // Float the red (invalid — no wall) ghost at the cursor, level-Y lifted to // the sill center (sideOverride carries the R-flip so the ghost matches). setGhostPose({ @@ -746,7 +758,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 +767,200 @@ 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) { + 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,13 +1002,16 @@ 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) { - useScene.getState().updateNode(movingWindowNode.id, { + markHostDirty(currentHostId) + currentHostId = target.segment.id + useLiveNodeOverrides.getState().set(movingWindowNode.id, { position: target.position, rotation: [0, 0, 0], side: 'front', @@ -812,10 +1021,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofFace: target.face.id, visible: true, }) - 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 +1076,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 +1109,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 +1118,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 +1126,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 +1138,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 +1166,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 +1196,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 +1231,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 +1303,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 +1318,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 +1334,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 +1349,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..4cac8a4616 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,551 @@ 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 pointInOrNearShedPolygon( + point: readonly [number, number], + polygon: RoofPlanPolygon, + tolerance: number, +): boolean { + if (pointInPolygon2D([point[0], point[1]], polygon, { includeBoundary: false })) return true + if (!(tolerance > 0)) return false + + const toleranceSquared = tolerance * tolerance + for (let index = 0; index < polygon.length; index++) { + const start = polygon[index]! + const end = polygon[(index + 1) % polygon.length]! + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + const ratio = + lengthSquared > 1e-12 + ? Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), + ) + : 0 + const closestX = start[0] + dx * ratio + const closestZ = start[1] + dz * ratio + const distanceSquared = + (point[0] - closestX) * (point[0] - closestX) + (point[1] - closestZ) * (point[1] - closestZ) + if (distanceSquared <= toleranceSquared) return true + } + return false +} + +function readMetadataString(node: AnyNode, key: string): string | undefined { + const metadata = node.metadata + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return undefined + const value = (metadata as Record)[key] + return typeof value === 'string' ? value : undefined +} + +// The lean-to assembly records, per managed shed segment, the ids of the +// neighbouring lean-tos it is actually joined to. Restricting sibling detection +// to these keeps a shed from mitering against a run it merely passes near in +// world space (e.g. the two free ends of a J that overlap across its mouth). +function shedJointNeighborLeanTos(node: RoofSegmentNode): Set | null { + const metadata = node.metadata + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).leanToShedJointNeighbors + if (!Array.isArray(value)) return null + const ids = value.filter((entry): entry is string => typeof entry === 'string') + return ids.length > 0 ? new Set(ids) : null +} + +function isJoinedShedSibling(node: RoofSegmentNode, candidate: RoofSegmentNode): boolean { + const neighbors = shedJointNeighborLeanTos(node) + if (!neighbors) return true + const candidateOwner = readMetadataString(candidate, 'managedByLeanTo') + return candidateOwner !== undefined && neighbors.has(candidateOwner) +} + +function shedJoinTolerance( + node: RoofSegmentNode, + sibling: RoofSegmentNode, + nodes?: Record, +): number { + const sideOverhang = (segment: RoofSegmentNode) => { + const structuralSpan = readFiniteNumber(segment.shedSideInfillSpan) + if (structuralSpan !== null) return Math.max(0, (segment.width - structuralSpan) / 2) + const managedBy = + segment.metadata && typeof segment.metadata === 'object' && !Array.isArray(segment.metadata) + ? (segment.metadata as Record).managedByLeanTo + : undefined + const owner = typeof managedBy === 'string' ? nodes?.[managedBy] : undefined + return owner?.type === 'lean-to-extension' + ? Math.max(owner.leftOverhang, owner.rightOverhang) + : 0 + } + return Math.max( + 0.02, + Math.min( + 0.2, + Math.max( + node.overhang, + sibling.overhang, + node.wallThickness, + sibling.wallThickness, + sideOverhang(node), + sideOverhang(sibling), + ), + ), + ) +} + +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' && + isJoinedShedSibling(node, candidate), + ) + 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) => + pointInOrNearShedPolygon(local, polygon, shedJoinTolerance(node, sibling, nodes)), + ) + }) + ) { + 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' && + isJoinedShedSibling(node, candidate), + ) + 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) => + pointInOrNearShedPolygon(local, polygon, shedJoinTolerance(node, sibling, nodes)), + ) + }) + }), + ) +} + +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[][] { + 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 + + 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] + } + // Geometric ownership: a run closes the step only along the stretch where its + // own roof top sits BELOW the sibling's (delta > 0), raising a vertical wall up + // to the sibling's top. `delta` is antisymmetric between the two runs, so every + // point of a seam is owned by exactly one run — the lower one — independent of + // run count, chain vs. loop topology, or random node ids. Flush stretches + // (|delta| <= tol) have no step and draw nothing, so a reversed/continuous fold + // stays open. This replaces the old id-order tiebreak and the "skip when joined + // on both ends" rule, which together left every seam of a closed loop unclosed. + const faces: THREE.Vector3[][] = [] + if (startDelta > tolerance && endDelta > tolerance) { + pushDoubleSidedFace(faces, face(start, startHeights, end, endHeights)) + } else if (startDelta > tolerance || endDelta > 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) + if (startDelta > tolerance) { + pushDoubleSidedFace(faces, face(start, startHeights, crossingPoint, crossingHeights)) + } else { + pushDoubleSidedFace(faces, face(crossingPoint, crossingHeights, 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 +2904,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 +3685,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 +3939,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 +3970,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