From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 1/9] Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 --- .../src/components/tools/item/move-tool.tsx | 6 +- .../components/tools/item/placement-math.ts | 26 ++++ .../tools/item/placement-strategies.ts | 88 ++++++++++++ .../components/tools/item/placement-types.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 135 +++++++++++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed205..eefaa2a799 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -40,12 +40,12 @@ function getInitialState(node: { }): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } + return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } } if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } + return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } + return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } } function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 49eacf304d..112273a41d 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { type AssetInput, isObject } from '@pascal-app/core' +import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -118,3 +119,28 @@ export function stripTransient(meta: any): any { const { isTransient, ...rest } = meta as Record return rest } + +const _up = new Vector3(0, 1, 0) +const _normal = new Vector3() +const _quat = new Quaternion() +const _euler = new Euler() + +/** + * Compute euler rotation that tilts an item so its local +Y aligns with a + * roof surface normal. The normal is in the hit mesh's local space and is + * transformed to world space via the mesh's matrixWorld. + */ +export function calculateRoofRotation( + normal: [number, number, number] | undefined, + objectMatrixWorld: Matrix4, +): [number, number, number] { + if (!normal) return [0, 0, 0] + + _normal.set(normal[0], normal[1], normal[2]) + _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize() + + _quat.setFromUnitVectors(_up, _normal) + _euler.setFromQuaternion(_quat, 'XYZ') + + return [_euler.x, _euler.y, _euler.z] +} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e87240810..5563268b8e 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,6 +6,7 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, WallEvent, WallNode, } from '@pascal-app/core' @@ -19,6 +20,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + calculateRoofRotation, getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, @@ -587,6 +589,87 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// ROOF STRATEGY +// ============================================================================ + +export const roofStrategy = { + enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + if (!ctx.levelId) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + stateUpdate: { surface: 'roof', roofId: event.node.id }, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + parentId: ctx.levelId, + rotation, + }, + cursorRotationY: rotation[1], + cursorRotation: rotation, + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + stopPropagation: true, + } + }, + + move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + cursorRotationY: rotation[1], + cursorRotation: rotation, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + rotation, + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof') return null + + return { + stateUpdate: { surface: 'floor', roofId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: [0, ctx.currentCursorRotationY, 0], + }, + cursorRotationY: ctx.currentCursorRotationY, + cursorRotation: [0, ctx.currentCursorRotationY, 0], + gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +686,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Roof: valid if we entered (no spatial validator yet) + if (ctx.state.surface === 'roof') { + return ctx.state.roofId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 5382865806..69a3d5ee3e 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,7 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + roofId: string | null } // ============================================================================ @@ -58,6 +59,7 @@ export interface PlacementResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] nodeUpdate: Partial | null stopPropagation: boolean dirtyNodeId: AnyNode['id'] | null @@ -72,6 +74,7 @@ export interface TransitionResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] stopPropagation: boolean } 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 fdafe3635d..bac2b78fc1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type RoofEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -484,7 +486,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } const draft = draftNode.current if (draft) { @@ -498,12 +504,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(...result.gridPosition) const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } + + const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0] draftNode.create( gridPosition.current, asset, - [0, result.cursorRotationY, 0], + initRotation, configRef.current.defaultScale, ) @@ -1065,6 +1077,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Segment Handlers ---- + + const toRoofLocal = (result: TransitionResult): TransitionResult => { + const local = worldToBuildingLocal(...result.cursorPosition) + const localPos: [number, number, number] = [local.x, local.y, local.z] + return { + ...result, + gridPosition: localPos, + nodeUpdate: { ...result.nodeUpdate, position: localPos }, + } + } + + const onRoofEnter = (event: RoofEvent) => { + const result = roofStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + const local = toRoofLocal(result) + applyTransition(local) + + if (!draftNode.current) { + ensureDraft(local) + } + } + + const onRoofMove = (event: RoofEvent) => { + const ctx = getContext() + + if (ctx.state.surface !== 'roof') { + const enterResult = roofStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + const local = toRoofLocal(enterResult) + applyTransition(local) + if (!draftNode.current) { + ensureDraft(local) + } + return + } + + if (!draftNode.current) { + const enterResult = roofStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(toRoofLocal(enterResult)) + return + } + + const result = roofStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + const localPos = worldToBuildingLocal(...result.cursorPosition) + gridPosition.current.set(localPos.x, localPos.y, localPos.z) + cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.y = result.cursorRotationY + } + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + draft.position = [localPos.x, localPos.y, localPos.z] + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + mesh.rotation.set(...result.cursorRotation) + } + } + } + + revalidate() + } + + const onRoofClick = (event: RoofEvent) => { + const result = roofStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + revalidate() + } + } + + const onRoofLeave = (event: RoofEvent) => { + const result = roofStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + applyTransition(result) + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1354,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) return () => { tearingDown = true @@ -1263,6 +1382,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1307,7 +1430,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'roof') { + mesh.position.copy(gridPosition.current) + } else if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 7c1e3839c95c184dadb2b9e761b5da0520598f29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 20 May 2026 17:21:10 +0530 Subject: [PATCH 2/9] fixed conflict --- .../src/components/tools/item/move-tool.tsx | 69 ---------- .../tools/item/placement-strategies.ts | 84 ------------ .../components/tools/item/placement-types.ts | 8 -- .../tools/item/use-placement-coordinator.tsx | 127 +----------------- 4 files changed, 1 insertion(+), 287 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2d7f857232..d7c86be966 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -15,76 +15,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -<<<<<<< HEAD -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} -======= import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * MoveTool dispatcher. Routes to (in order): diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index fae9694e93..df67ca1690 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,8 @@ import type { GridEvent, ItemEvent, ItemNode, -<<<<<<< HEAD - RoofEvent, -======= ShelfEvent, ShelfNode, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 WallEvent, WallNode, } from '@pascal-app/core' @@ -596,29 +592,6 @@ export const itemSurfaceStrategy = { } // ============================================================================ -<<<<<<< HEAD -// ROOF STRATEGY -// ============================================================================ - -export const roofStrategy = { - enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { - if (ctx.asset.attachTo) return null - if (!ctx.levelId) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - stateUpdate: { surface: 'roof', roofId: event.node.id }, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - parentId: ctx.levelId, - rotation, - }, - cursorRotationY: rotation[1], - cursorRotation: rotation, - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], -======= // SHELF SURFACE STRATEGY // ============================================================================ @@ -703,28 +676,10 @@ export const shelfSurfaceStrategy = { cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, rowY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, } }, -<<<<<<< HEAD - move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], - cursorRotationY: rotation[1], - cursorRotation: rotation, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - rotation, - }, -======= /** * Handle shelf:move — re-derive the closest row each tick so the user * can slide between rows without leaving the shelf. @@ -753,17 +708,11 @@ export const shelfSurfaceStrategy = { cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, rowY, z] }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null -======= /** * Handle shelf:click — commit placement on the active row. */ @@ -771,43 +720,17 @@ export const shelfSurfaceStrategy = { if (ctx.state.surface !== 'shelf-surface') return null if (!(ctx.draftItem && ctx.state.shelfId)) return null if (event.node.id !== ctx.state.shelfId) return null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], -<<<<<<< HEAD - parentId: ctx.levelId, - rotation: ctx.draftItem.rotation, -======= parentId: ctx.state.shelfId, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - - leave(ctx: PlacementContext): TransitionResult | null { - if (ctx.state.surface !== 'roof') return null - - return { - stateUpdate: { surface: 'floor', roofId: null }, - nodeUpdate: { - position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - parentId: ctx.levelId, - rotation: [0, ctx.currentCursorRotationY, 0], - }, - cursorRotationY: ctx.currentCursorRotationY, - cursorRotation: [0, ctx.currentCursorRotationY, 0], - gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - stopPropagation: true, - } - }, -======= } /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed @@ -816,7 +739,6 @@ export const shelfSurfaceStrategy = { * `event.normal` + `event.object`. */ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { return isUpwardItemSurfaceHit(event as unknown as ItemEvent) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ @@ -835,15 +757,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } -<<<<<<< HEAD - // Roof: valid if we entered (no spatial validator yet) - if (ctx.state.surface === 'roof') { - return ctx.state.roofId !== null -======= // Shelf surface: same — size check already happened on enter if (ctx.state.surface === 'shelf-surface') { return ctx.state.shelfId !== null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } const attachTo = ctx.draftItem.asset.attachTo diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 0a593ca750..a3eccc116d 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,11 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -<<<<<<< HEAD -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' -======= export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * Tracks which surface the draft item is currently on. @@ -27,9 +23,6 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null -<<<<<<< HEAD - roofId: string | null -======= /** * Active shelf when `surface === 'shelf-surface'`. Items host on the * shelf board closest to the cursor's local Y; the row index isn't @@ -37,7 +30,6 @@ export interface PlacementState { * position via `shelfRowSurfaceYs`. */ shelfId: string | null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ 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 362ddd1ddc..b86e426c47 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,11 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, -<<<<<<< HEAD - type RoofEvent, -======= type ShelfEvent, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 sceneRegistry, spatialGridManager, useLiveTransforms, @@ -46,11 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, -<<<<<<< HEAD - roofStrategy, -======= shelfSurfaceStrategy, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -296,9 +288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( -<<<<<<< HEAD - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, -======= config.initialState ?? { surface: 'floor', wallId: null, @@ -306,7 +295,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -1206,58 +1194,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } -<<<<<<< HEAD - // ---- Roof Segment Handlers ---- - - const toRoofLocal = (result: TransitionResult): TransitionResult => { - const local = worldToBuildingLocal(...result.cursorPosition) - const localPos: [number, number, number] = [local.x, local.y, local.z] - return { - ...result, - gridPosition: localPos, - nodeUpdate: { ...result.nodeUpdate, position: localPos }, - } - } - - const onRoofEnter = (event: RoofEvent) => { - const result = roofStrategy.enter(getContext(), event) - if (!result) return - - event.stopPropagation() - const local = toRoofLocal(result) - applyTransition(local) - - if (!draftNode.current) { - ensureDraft(local) - } - } - - const onRoofMove = (event: RoofEvent) => { - const ctx = getContext() - - if (ctx.state.surface !== 'roof') { - const enterResult = roofStrategy.enter(ctx, event) - if (!enterResult) return - - event.stopPropagation() - const local = toRoofLocal(enterResult) - applyTransition(local) - if (!draftNode.current) { - ensureDraft(local) - } - return - } - - if (!draftNode.current) { - const enterResult = roofStrategy.enter(getContext(), event) - if (!enterResult) return - event.stopPropagation() - ensureDraft(toRoofLocal(enterResult)) - return - } - - const result = roofStrategy.move(ctx, event) -======= // ---- Shelf Handlers ---- // // Items can host on shelves the same way they host on tables and @@ -1299,34 +1235,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } const result = shelfSurfaceStrategy.move(ctx, event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() -<<<<<<< HEAD - const localPos = worldToBuildingLocal(...result.cursorPosition) - gridPosition.current.set(localPos.x, localPos.y, localPos.z) - cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - cursorGroupRef.current.rotation.set(...result.cursorRotation) - } else { - cursorGroupRef.current.rotation.y = result.cursorRotationY - } - - const draft = draftNode.current - if (draft && result.nodeUpdate) { - if ('rotation' in result.nodeUpdate) - draft.rotation = result.nodeUpdate.rotation as [number, number, number] - draft.position = [localPos.x, localPos.y, localPos.z] - const mesh = sceneRegistry.nodes.get(draft.id) - if (mesh) { - mesh.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - mesh.rotation.set(...result.cursorRotation) - } - } -======= gridPosition.current.set(...result.gridPosition) const ic = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) @@ -1341,16 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea position: result.cursorPosition, rotation: result.cursorRotationY, }) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } revalidate() } -<<<<<<< HEAD - const onRoofClick = (event: RoofEvent) => { - const result = roofStrategy.click(getContext(), event) -======= const onShelfLeave = (event: ShelfEvent) => { if (placementState.current.surface !== 'shelf-surface') return if (event.node.id !== placementState.current.shelfId) return @@ -1363,7 +1270,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onShelfClick = (event: ShelfEvent) => { const result = shelfSurfaceStrategy.click(getContext(), event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() @@ -1373,20 +1279,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { -<<<<<<< HEAD - revalidate() - } - } - - const onRoofLeave = (event: RoofEvent) => { - const result = roofStrategy.leave(getContext()) - if (!result) return - - event.stopPropagation() - applyTransition(result) - } - -======= const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -1396,7 +1288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1571,17 +1462,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.on('roof:enter', onRoofEnter) - emitter.on('roof:move', onRoofMove) - emitter.on('roof:click', onRoofClick) - emitter.on('roof:leave', onRoofLeave) -======= emitter.on('shelf:enter', onShelfEnter) emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return () => { tearingDown = true @@ -1606,17 +1490,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.off('roof:enter', onRoofEnter) - emitter.off('roof:move', onRoofMove) - emitter.off('roof:click', onRoofClick) - emitter.off('roof:leave', onRoofLeave) -======= emitter.off('shelf:enter', onShelfEnter) emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1667,9 +1544,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'roof') { - mesh.position.copy(gridPosition.current) - } else if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From fc58e4ff7c65d44da9338e012b79137f4f6ead5c Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 24 Aug 2026 18:23:01 +0530 Subject: [PATCH 3/9] feat: improve roof placement and hosted extensions --- apps/editor/components/build-tab.tsx | 92 +++-- apps/editor/lib/build-tab-state.test.ts | 27 ++ apps/editor/lib/build-tab-state.ts | 19 + packages/core/src/index.ts | 10 + .../src/lib/conical-roof-placement.test.ts | 138 +++++++ .../core/src/lib/conical-roof-placement.ts | 210 +++++++++++ packages/core/src/lib/roof-overlap.test.ts | 51 ++- packages/core/src/lib/roof-overlap.ts | 30 ++ packages/core/src/registry/handles.ts | 7 + packages/core/src/schema/index.ts | 6 +- packages/core/src/schema/nodes/dormer.ts | 100 ++++- .../src/schema/nodes/lean-to-extension.ts | 6 +- packages/core/src/schema/nodes/ridge-vent.ts | 8 +- .../schema/nodes/roof-segment-shape.test.ts | 29 ++ .../src/schema/nodes/roof-segment-shape.ts | 43 ++- .../schema/nodes/roof-segment-surface.test.ts | 15 + .../core/src/schema/nodes/roof-segment.ts | 22 +- packages/core/src/schema/nodes/roof.ts | 16 + packages/core/src/schema/nodes/window.ts | 4 + .../store/use-scene-window-migration.test.ts | 38 ++ packages/core/src/store/use-scene.ts | 25 ++ .../core/src/utils/clone-scene-graph.test.ts | 51 +++ packages/core/src/utils/clone-scene-graph.ts | 10 + .../editor/handles/resize-snap.test.ts | 33 ++ .../components/editor/handles/resize-snap.ts | 7 +- .../components/editor/node-arrow-handles.tsx | 24 +- .../tools/roof/roof-placement-mode.test.ts | 16 + .../tools/roof/roof-placement-mode.ts | 19 + .../src/components/tools/roof/roof-tool.tsx | 223 +++++++++-- .../src/components/ui/helpers/roof-helper.tsx | 28 +- .../panels/site-panel/dormer-tree-node.tsx | 24 +- packages/editor/src/index.tsx | 6 +- .../src/lib/direct-manipulation.test.ts | 73 ++++ .../editor/src/lib/direct-manipulation.ts | 26 ++ .../editor/src/lib/elevation-guides.test.ts | 23 ++ packages/editor/src/lib/elevation-guides.ts | 20 +- .../editor/src/lib/print-roof-solids.test.ts | 13 +- packages/editor/src/lib/print-roof-solids.ts | 2 +- .../lib/print-shell-compiler-baseline.test.ts | 13 +- packages/mcp/src/tools/construction-tools.ts | 24 +- packages/nodes/src/dormer/csg-geometry.ts | 81 +++- packages/nodes/src/dormer/definition.ts | 16 +- packages/nodes/src/dormer/move-tool.tsx | 31 +- .../src/dormer/panel-windows-section.tsx | 84 +++++ packages/nodes/src/dormer/panel.tsx | 126 ++++++- packages/nodes/src/dormer/renderer.tsx | 73 +++- packages/nodes/src/dormer/tool.tsx | 7 +- .../nodes/src/dormer/window-layout.test.ts | 43 +++ packages/nodes/src/dormer/window-layout.ts | 86 +++++ .../nodes/src/lean-to-extension/assembly.ts | 17 +- .../lean-to-extension/conical-host.test.ts | 113 ++++++ .../src/lean-to-extension/conical-host.ts | 146 +++++++ .../src/lean-to-extension/corner-joint.ts | 352 ++++++++++++++--- .../src/lean-to-extension/definition.test.ts | 192 +++++++++- .../nodes/src/lean-to-extension/definition.ts | 355 +++++++++++------- .../floorplan-affordances.ts | 38 +- .../lean-to-extension/floorplan-move.test.ts | 112 ++++++ .../src/lean-to-extension/floorplan-move.ts | 65 +++- .../src/lean-to-extension/floorplan-tool.tsx | 91 +++-- .../src/lean-to-extension/floorplan.test.ts | 39 ++ .../nodes/src/lean-to-extension/floorplan.ts | 111 ++++++ .../src/lean-to-extension/layout.test.ts | 190 +++++++++- .../nodes/src/lean-to-extension/layout.ts | 295 +++++++++++++-- .../lean-to-extension/linear-joint.test.ts | 118 ++++++ .../src/lean-to-extension/managed-preview.ts | 83 ++++ .../nodes/src/lean-to-extension/move-tool.tsx | 82 +++- .../placement-validation.test.ts | 45 +++ .../lean-to-extension/placement-validation.ts | 6 +- .../src/lean-to-extension/roof-attachment.ts | 33 ++ .../src/lean-to-extension/roof-corner.test.ts | 163 +++++++- .../src/lean-to-extension/system.test.ts | 146 +++++++ .../nodes/src/lean-to-extension/system.tsx | 27 +- packages/nodes/src/lean-to-extension/tool.tsx | 186 ++++++++- .../src/lean-to-extension/wall-target.test.ts | 41 ++ .../src/lean-to-extension/wall-target.ts | 38 ++ .../nodes/src/roof-segment/definition.test.ts | 17 + packages/nodes/src/roof-segment/definition.ts | 12 +- .../src/roof-segment/floorplan-affordances.ts | 26 +- .../nodes/src/roof-segment/floorplan.test.ts | 34 +- packages/nodes/src/roof-segment/floorplan.ts | 84 +++-- packages/nodes/src/roof-segment/panel.tsx | 235 +++++++----- packages/nodes/src/roof/floorplan.test.ts | 51 +++ packages/nodes/src/roof/floorplan.ts | 46 ++- .../dormer-wall-opening-placement.test.ts | 160 ++++++++ .../shared/dormer-wall-opening-placement.ts | 127 +++++++ packages/nodes/src/shared/ridge-snap.ts | 2 +- packages/nodes/src/shared/roof-surface.ts | 13 +- packages/nodes/src/window/definition.ts | 4 +- packages/nodes/src/window/move-tool.tsx | 199 ++++++++++ packages/nodes/src/window/panel.tsx | 2 + packages/nodes/src/window/tool.tsx | 244 +++++++++++- .../roof/roof-system-intersection.test.ts | 82 ++++ .../src/systems/roof/roof-system.test.ts | 68 +++- .../viewer/src/systems/roof/roof-system.tsx | 57 ++- wiki/architecture/tools.md | 5 + wiki/conical-roof-implementation-plan.md | 52 +++ wiki/conical-turret-roof-research.md | 119 ++++++ 97 files changed, 6121 insertions(+), 680 deletions(-) create mode 100644 apps/editor/lib/build-tab-state.test.ts create mode 100644 apps/editor/lib/build-tab-state.ts create mode 100644 packages/core/src/lib/conical-roof-placement.test.ts create mode 100644 packages/core/src/lib/conical-roof-placement.ts create mode 100644 packages/editor/src/components/tools/roof/roof-placement-mode.test.ts create mode 100644 packages/editor/src/components/tools/roof/roof-placement-mode.ts create mode 100644 packages/nodes/src/dormer/panel-windows-section.tsx create mode 100644 packages/nodes/src/dormer/window-layout.test.ts create mode 100644 packages/nodes/src/dormer/window-layout.ts create mode 100644 packages/nodes/src/lean-to-extension/conical-host.test.ts create mode 100644 packages/nodes/src/lean-to-extension/conical-host.ts create mode 100644 packages/nodes/src/lean-to-extension/floorplan-move.test.ts create mode 100644 packages/nodes/src/lean-to-extension/linear-joint.test.ts create mode 100644 packages/nodes/src/lean-to-extension/managed-preview.ts create mode 100644 packages/nodes/src/lean-to-extension/wall-target.test.ts create mode 100644 packages/nodes/src/lean-to-extension/wall-target.ts create mode 100644 packages/nodes/src/shared/dormer-wall-opening-placement.test.ts create mode 100644 packages/nodes/src/shared/dormer-wall-opening-placement.ts create mode 100644 wiki/conical-roof-implementation-plan.md create mode 100644 wiki/conical-turret-roof-research.md diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 5ba049ddbe..32b9275776 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,6 +1,6 @@ 'use client' -import { nodeRegistry } from '@pascal-app/core' +import { nodeRegistry, type RoofType, useRegistryVersion } from '@pascal-app/core' import { type FloorplanMode, getFloorplanNodeExtension, @@ -13,13 +13,14 @@ import { } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' -import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/toolbar-tooltip' +import { getActiveRoofFeatureId } from '@/lib/build-tab-state' import { cn } from '@/lib/utils' /** @@ -169,10 +170,44 @@ 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 + roofType?: RoofType +} const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp' +function collectRoofFeatures(): RoofFeature[] { + const features: RoofFeature[] = [ + { + id: 'roof-shape:conical', + label: 'Conical roof', + iconSrc: ROOF_FEATURE_FALLBACK_ICON, + roofType: 'conical', + }, + ] + 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 +216,18 @@ 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.roofType) { + ed.setToolDefaults('roof', { roofType: feature.roofType }) + ed.setTool('roof') + return + } + if (feature.kind) ed.setTool(feature.kind) } /** @@ -207,19 +247,18 @@ const MEP_TOOL_KINDS = new Set([ export function BuildTab() { const activeTool = useEditor((s) => s.tool) + const activeRoofType = useEditor((s) => s.toolDefaults.roof?.roofType) const mode = useEditor((s) => s.mode) 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 +281,7 @@ export function BuildTab() { // Read at render time (not module scope): the registry is populated by the // app bootstrap, so enumerating earlier would race it and see no kinds. - const roofFeatures = useMemo(() => { - if (!registryReady) return [] - const features: RoofFeature[] = [] - for (const [kind, def] of nodeRegistry.entries()) { - if ( - def.capabilities.roofAccessory === undefined && - def.presentation?.paletteGroup !== 'roof-features' - ) { - continue - } - // Door / window declare `roofAccessory` for the wall-face cut but - // already have their own Build tiles — listing them here too - // would duplicate the entry under Roof → Features. - if (def.capabilities.wallOpeningPlacement) continue - const icon = def.presentation?.icon - features.push({ - kind, - label: def.presentation?.label ?? kind, - iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, - }) - } - return features - }, [registryReady]) + const roofFeatures = registryReady ? collectRoofFeatures() : [] // Tile highlight derives from the single source of truth (the active tool / // mode), never a separate local selection — so keyboard shortcuts and panel @@ -272,8 +289,8 @@ export function BuildTab() { // The roof Features sub-grid arms roof-accessory tools (skylight, chimney, // …); keep the Roof tile lit (and its panel open) while any of them is the // active tool, the same way MEP stays lit for its sub-grid tools. - const isRoofFeatureActive = - mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool) + const activeRoofFeatureId = getActiveRoofFeatureId(roofFeatures, activeTool, activeRoofType) + const isRoofFeatureActive = mode === 'build' && activeRoofFeatureId !== null const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool) const isTypeActive = (type: BuildType) => { @@ -377,11 +394,12 @@ export function BuildTab() { style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }} > {roofFeatures.map((feature) => { - const active = mode === 'build' && activeTool === feature.kind + const active = mode === 'build' && feature.id === activeRoofFeatureId return ( - + + + + + ))} + + ) : ( +
No windows
+ )} + +
+ } + label="Add Window" + onClick={onAdd} + /> + {!canAdd && ( +

+ Increase the dormer width to add another window. +

+ )} +
+ + ) +} diff --git a/packages/nodes/src/dormer/panel.tsx b/packages/nodes/src/dormer/panel.tsx index dd19c16dfa..eb36b57c05 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,9 +23,11 @@ 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 DormerSection = 'dormer' | 'window' @@ -38,7 +44,7 @@ const ROOF_TYPE_OPTIONS: Array<{ label: string; value: RoofType }> = [ 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 +62,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 +125,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 +158,67 @@ 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 +228,19 @@ 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 canAddWindow = + planDormerWindowRow(node.width, [ + ...frontWindows, + { + id: 'window_preview', + position: [0, templateWindow?.position[1] ?? 0, 0], + width: templateWindow?.width ?? node.windowWidth, + }, + ]) !== null return ( )} diff --git a/packages/nodes/src/dormer/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index 8199ec80db..8788a4a771 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -1,24 +1,30 @@ '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, @@ -45,6 +51,18 @@ 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 hostedWindows = useMemo( + () => childNodes.filter((child): child is WindowNode => child.type === 'window'), + [childNodes], + ) + const segment = useScene((state) => node.roofSegmentId ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) @@ -117,7 +135,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { const geometry = useMemo(() => { if (!segment) return null if (isLiveDrag) return buildDormerFallbackGeometry(node) - return generateDormerGeometry(node, segment) + return generateDormerGeometry(node, segment, hostedWindows) }, [ isLiveDrag, segment, @@ -142,6 +160,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { node.windowCornerRadii[1], node.windowCornerRadii[2], node.windowCornerRadii[3], + hostedWindows, ]) useEffect(() => () => geometry?.dispose(), [geometry]) @@ -174,12 +193,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.length === 0 && ( + + )} + {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..e1c5e6a998 100644 --- a/packages/nodes/src/dormer/tool.tsx +++ b/packages/nodes/src/dormer/tool.tsx @@ -1,6 +1,6 @@ 'use client' -import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core' +import { type AnyNodeId, createDormerDefaultWindow, DormerNode, useScene } from '@pascal-app/core' import { usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo } from 'react' @@ -66,6 +66,11 @@ const DormerTool = () => { rotation, }) state.createNode(dormer, hit.segment.id as AnyNodeId) + const defaultWindow = createDormerDefaultWindow( + dormer, + `window_${dormer.id.replace(/^dormer_/, '')}_default`, + ) + 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-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.ts b/packages/nodes/src/lean-to-extension/assembly.ts index 674befd216..3cc4c3c1dd 100644 --- a/packages/nodes/src/lean-to-extension/assembly.ts +++ b/packages/nodes/src/lean-to-extension/assembly.ts @@ -21,6 +21,7 @@ import { import { resolveEaveSnap } from '../gutter/eave-snap' import { getRoofTopSurfaceY } from '../shared/roof-surface' import { bendLocalPoint, bendRotationYAtLocalX, isCurvedLeanTo } from './arc' +import { isClosedLoopLeanTo } from './conical-host' import { applyLeanToCornerRoofPieces, LEAN_TO_CORNER_JOINTS_KEY, @@ -336,8 +337,10 @@ export function resolveLeanToPostIndexes( 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 }) @@ -411,6 +414,9 @@ export function leanToRoofSegmentLayoutPatch( polygon.map(([x = 0, z = 0]) => [x - roofCenterX, z - roofCenterZ] as [number, number]), ) const jointSides = Object.values(cornerJoints).flatMap((joint) => (joint ? [joint.side] : [])) + const hasShapedCorner = Object.values(cornerJoints).some( + (joint) => joint && joint.kind !== 'linear', + ) const sideMemberFaceInset = Math.min( Math.max(0, leanTo.rafterWidth / 2), Math.max(0, layout.span / 2 - 0.01), @@ -447,7 +453,7 @@ export function leanToRoofSegmentLayoutPatch( shedSideInfillSpan: layout.span, shedSideInfillMinX: -layout.span / 2 - sideMemberFaceInset - roofCenterX, shedSideInfillMaxX: layout.span / 2 + sideMemberFaceInset - roofCenterX, - shedFootprintPieces: jointSides.length > 0 ? roofPieces : undefined, + shedFootprintPieces: hasShapedCorner ? roofPieces : undefined, shedOpenEndSides: jointSides.length > 0 ? jointSides : undefined, metadata: managedMetadata(leanTo, 'roof-segment'), trim: { @@ -486,6 +492,8 @@ export function leanToGutterLayoutPatch( | 'visible' | 'profile' | 'size' + | 'endCapLeft' + | 'endCapRight' | 'outlets' | 'metadata' > { @@ -530,6 +538,11 @@ export function leanToGutterLayoutPatch( const neighbor = nodes[joint.neighborId] return neighbor?.type === 'lean-to-extension' && neighbor.gutterEnabled ? joint.gutterMitre : 0 } + const gutterOpenAtJoint = (joint: LeanToCornerJoint | undefined): boolean => { + if (!(leanTo.gutterEnabled && joint && nodes)) return false + const neighbor = nodes[joint.neighborId] + return neighbor?.type === 'lean-to-extension' && neighbor.gutterEnabled + } const length = Math.max(0.05, segment.width + 2 * segment.overhang) const jointAwareDownspoutPosition = cornerJoints.left && leanTo.downspoutPosition < -0.75 @@ -583,6 +596,8 @@ export function leanToGutterLayoutPatch( visible: leanTo.gutterEnabled, profile: leanTo.gutterProfile, size: leanTo.gutterSize, + endCapLeft: !isClosedLoopLeanTo(leanTo) && !gutterOpenAtJoint(cornerJoints.left), + endCapRight: !isClosedLoopLeanTo(leanTo) && !gutterOpenAtJoint(cornerJoints.right), outlets: leanTo.gutterEnabled && leanTo.downspoutEnabled ? [outlet] : [], metadata: { ...metadataRecord(gutter?.metadata), 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..51a7145450 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/conical-host.test.ts @@ -0,0 +1,113 @@ +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('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() + }) +}) 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..3b64b3c03f --- /dev/null +++ b/packages/nodes/src/lean-to-extension/conical-host.ts @@ -0,0 +1,146 @@ +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 highEdgeHeight = Math.max(0.8, segment.wallHeight) + 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', + 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, +): 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 (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..515838120d 100644 --- a/packages/nodes/src/lean-to-extension/corner-joint.ts +++ b/packages/nodes/src/lean-to-extension/corner-joint.ts @@ -1,10 +1,11 @@ import type { AnyNode, LeanToExtensionNode, WallNode } from '@pascal-app/core' import { bendLocalPoint, isCurvedLeanTo, leanToArcFrameAtLocalX } from './arc' import { leanToWallLocalPose, resolveLeanToLayout } from './layout' +import { applyLeanToWallCornerSpan } from './roof-attachment' export type LeanToCornerSide = 'left' | 'right' export type LeanToPlanPoint = [number, number] -export type LeanToCornerKind = 'convex' | 'concave' +export type LeanToCornerKind = 'convex' | 'concave' | 'linear' export type LeanToCornerJoint = { side: LeanToCornerSide @@ -27,6 +28,9 @@ const WALL_CONNECTION_TRIM = 0.002 const PLAN_TOLERANCE = 1e-6 const MIN_CORNER_ANGLE = Math.PI / 6 const MAX_CORNER_ANGLE = (5 * Math.PI) / 6 +const LINEAR_DIRECTION_TOLERANCE = 1e-3 +const LINEAR_JOIN_PLAN_TOLERANCE = 0.03 +const LINEAR_JOIN_HEIGHT_TOLERANCE = 0.02 function planDistance(a: readonly [number, number], b: readonly [number, number]): number { return Math.hypot(a[0] - b[0], a[1] - b[1]) @@ -106,6 +110,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 +124,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, @@ -570,6 +675,52 @@ function resolveConcaveRoofPiece( } } +function resolveCurvedStraightConcaveRoofPiece( + leanTo: LeanToExtensionNode, + wall: WallNode, + side: LeanToCornerSide, + candidate: LeanToExtensionNode, + candidateWall: WallNode, + candidateSide: LeanToCornerSide, +): { piece: LeanToPlanPoint[]; seam: [LeanToPlanPoint, LeanToPlanPoint] | null } | null { + const ownCurved = isCurvedLeanTo(leanTo) + const candidateCurved = isCurvedLeanTo(candidate) + if (ownCurved === candidateCurved) return null + const direct = resolveConcaveRoofPiece(leanTo, wall, side, candidate, candidateWall) + if ((direct.piece.length >= 3 && direct.seam) || !ownCurved) return null + + // At a semicircle the curved parameter-space boundary can touch the same + // roof plane at both ends, while the straight neighbor still yields the seam. + const reciprocal = resolveConcaveRoofPiece( + candidate, + candidateWall, + candidateSide, + leanTo, + wall, + ) + if (!reciprocal.seam) return null + const seamWorld = reciprocal.seam.map((point) => + leanToPointToWorld(candidateWall, candidate, point[0], point[1]), + ) + if (seamWorld.some((point) => !point)) return null + const localized = seamWorld.map((point) => worldPointToLeanTo(wall, leanTo, point!)) + if (localized.some((point) => !point)) return null + + const layout = resolveLeanToLayout(leanTo) + const edges = roofPlanEdges(leanTo) + const [backSeam, frontSeam] = (localized as LeanToPlanPoint[]).sort( + (left, right) => left[1] - right[1], + ) as [LeanToPlanPoint, LeanToPlanPoint] + const leftX = layout.roofCenterX - layout.roofWidth / 2 + const rightX = layout.roofCenterX + layout.roofWidth / 2 + const piece: LeanToPlanPoint[] = + side === 'left' + ? [backSeam, [rightX, edges.back], [rightX, edges.front], frontSeam] + : [[leftX, edges.back], backSeam, frontSeam, [leftX, edges.front]] + + return { piece, seam: [backSeam, frontSeam] } +} + export function applyLeanToCornerRoofPieces( base: LeanToPlanPoint[], joints: Partial>, @@ -688,8 +839,9 @@ export function resolveLeanToCornerJoints( wall: WallNode | undefined, nodes: Record | undefined, ): Partial> { - if (!leanTo.autoMiterCorners || !wall || !nodes) return {} + if (!wall || !nodes) return {} if (!wallFrame(wall)) return {} + const cornerLeanTo = applyLeanToWallCornerSpan(leanTo, wall) const tolerance = Math.max( 0.35, (wall.thickness ?? 0.1) + Math.max(leanTo.leftOverhang, leanTo.rightOverhang), @@ -697,116 +849,196 @@ export function resolveLeanToCornerJoints( const joints: Partial> = {} for (const side of ['left', 'right'] as const) { - const endpoint = endWorldPoint(wall, leanTo, side) - if (!endpoint) continue + const endpoint = endWorldPoint(wall, cornerLeanTo, side) + const roofEndpoint = roofEndWorldPoint(wall, cornerLeanTo, side) + if (!(endpoint && roofEndpoint)) continue for (const candidate of Object.values(nodes)) { if (candidate.type !== 'lean-to-extension' || candidate.id === leanTo.id) continue - if (!candidate.autoMiterCorners) continue const candidateWall = candidate.parentId ? nodes[candidate.parentId] : undefined if (candidateWall?.type !== 'wall' || candidateWall.parentId !== wall.parentId) continue if (!wallFrame(candidateWall)) continue - const neighborSide = candidateSideAtPoint(candidateWall, candidate, endpoint, tolerance) + const cornerCandidate = applyLeanToWallCornerSpan(candidate, candidateWall) + const linearNeighborSide = candidateRoofSideAtPoint( + candidateWall, + cornerCandidate, + roofEndpoint, + ) + if (linearNeighborSide) { + const linearKind = cornerKindFromDirections( + wall, + cornerLeanTo, + side, + candidateWall, + cornerCandidate, + linearNeighborSide, + ) + const linearJoint = + linearKind === 'linear' + ? resolveLinearJoint( + wall, + cornerLeanTo, + side, + candidateWall, + cornerCandidate, + linearNeighborSide, + ) + : null + if (linearJoint) { + joints[side] = { + side, + kind: 'linear', + neighborId: candidate.id, + neighborSide: linearNeighborSide, + roofExtension: 0, + roofPiece: linearJoint.roofPiece, + seam: linearJoint.seam, + beamExtension: linearJoint.beamExtension, + gutterMitre: 0, + sharedPostOwner: String(cornerLeanTo.id) < String(candidate.id), + sharedPostPosition: linearJoint.sharedPostPosition, + } + break + } + } + if (!leanTo.autoMiterCorners || !candidate.autoMiterCorners) continue + const neighborSide = candidateSideAtPoint(candidateWall, cornerCandidate, endpoint, tolerance) if (!neighborSide) continue const kind = cornerKindFromDirections( wall, - leanTo, + cornerLeanTo, side, candidateWall, - candidate, + cornerCandidate, neighborSide, ) - if (!kind) continue - if (kind === 'concave' && (isCurvedLeanTo(leanTo) || isCurvedLeanTo(candidate))) continue - if (!isSupportedHostCorner(wall, leanTo, side, candidateWall, candidate, neighborSide)) { + if (!kind || kind === 'linear') continue + if ( + !isSupportedHostCorner( + wall, + cornerLeanTo, + side, + candidateWall, + cornerCandidate, + neighborSide, + ) + ) { continue } const interiorAngle = cornerInteriorAngle( wall, - leanTo, + cornerLeanTo, side, candidateWall, - candidate, + cornerCandidate, neighborSide, ) if (interiorAngle === null) continue - const candidateLayout = resolveLeanToLayout(candidate) - const layout = resolveLeanToLayout(leanTo) + const candidateLayout = resolveLeanToLayout(cornerCandidate) + const layout = resolveLeanToLayout(cornerLeanTo) + // A curved concave join is trimmed at the shared roof seam. Extending + // the run from a straight chord into the curved band is not a valid + // construction: the line/circle intersection can select the distant + // branch and create runaway beam and gutter lengths. + const curvedConcaveJoint = + kind === 'concave' && (isCurvedLeanTo(cornerLeanTo) || isCurvedLeanTo(cornerCandidate)) const sideSign = side === 'left' ? -1 : 1 - const ownEdges = roofPlanEdges(leanTo) - const candidateEdges = roofPlanEdges(candidate) + const ownEdges = roofPlanEdges(cornerLeanTo) + const candidateEdges = roofPlanEdges(cornerCandidate) const roofSideX = layout.roofCenterX + sideSign * (layout.roofWidth / 2) - const roofExtension = - extensionToRunIntersection( - wall, - leanTo, - side, - roofSideX, - ownEdges.front, - candidateWall, - candidate, - candidateEdges.front, - ) ?? 0 + const roofExtension = curvedConcaveJoint + ? 0 + : (extensionToRunIntersection( + wall, + cornerLeanTo, + side, + roofSideX, + ownEdges.front, + candidateWall, + cornerCandidate, + candidateEdges.front, + ) ?? 0) const candidateSideSign = neighborSide === 'left' ? -1 : 1 const candidateRoofSideX = candidateLayout.roofCenterX + candidateSideSign * (candidateLayout.roofWidth / 2) - const candidateRoofExtension = - extensionToRunIntersection( - candidateWall, - candidate, - neighborSide, - candidateRoofSideX, - candidateEdges.front, - wall, - leanTo, - ownEdges.front, - ) ?? 0 + const candidateRoofExtension = curvedConcaveJoint + ? 0 + : (extensionToRunIntersection( + candidateWall, + cornerCandidate, + neighborSide, + candidateRoofSideX, + candidateEdges.front, + wall, + cornerLeanTo, + ownEdges.front, + ) ?? 0) const curvedStraightRoof = kind === 'convex' ? resolveCurvedStraightRoofPiece( - leanTo, + cornerLeanTo, wall, side, roofExtension, - candidate, + cornerCandidate, candidateWall, neighborSide, candidateRoofExtension, ) : null + const curvedStraightConcaveRoof = + kind === 'concave' + ? resolveCurvedStraightConcaveRoofPiece( + cornerLeanTo, + wall, + side, + cornerCandidate, + candidateWall, + neighborSide, + ) + : null const roof = curvedStraightRoof ?? + curvedStraightConcaveRoof ?? (kind === 'convex' - ? resolveRoofPiece(leanTo, wall, side, roofExtension, candidate, candidateWall) - : resolveConcaveRoofPiece(leanTo, wall, side, candidate, candidateWall)) - const seam = curvedStraightRoof + ? resolveRoofPiece( + cornerLeanTo, + wall, + side, + roofExtension, + cornerCandidate, + candidateWall, + ) + : resolveConcaveRoofPiece(cornerLeanTo, wall, side, cornerCandidate, candidateWall)) + const seam = curvedStraightRoof || curvedStraightConcaveRoof ? roof.seam : sharedRoofSeam( wall, - leanTo, + cornerLeanTo, side, roofExtension, candidateWall, - candidate, + cornerCandidate, neighborSide, candidateRoofExtension, kind, ) - const beamExtension = - extensionToRunIntersection( - wall, - leanTo, - side, - sideSign * (layout.span / 2), - layout.beamZ, - candidateWall, - candidate, - candidateLayout.beamZ, - ) ?? 0 - const gutterAway = gutterAwayFromJointDirection(wall, leanTo, side, roofExtension) + const beamExtension = curvedConcaveJoint + ? 0 + : (extensionToRunIntersection( + wall, + cornerLeanTo, + side, + sideSign * (layout.span / 2), + layout.beamZ, + candidateWall, + cornerCandidate, + candidateLayout.beamZ, + ) ?? 0) + const gutterAway = gutterAwayFromJointDirection(wall, cornerLeanTo, side, roofExtension) const candidateGutterAway = gutterAwayFromJointDirection( candidateWall, - candidate, + cornerCandidate, neighborSide, candidateRoofExtension, ) @@ -832,7 +1064,7 @@ export function resolveLeanToCornerJoints( seam: seam ?? roof.seam, beamExtension, gutterMitre: (kind === 'concave' ? -1 : 1) * ((Math.PI - gutterInteriorAngle) / 2), - sharedPostOwner: String(leanTo.id) < String(candidate.id), + sharedPostOwner: String(cornerLeanTo.id) < String(candidate.id), sharedPostPosition: [ (side === 'left' ? -layout.span / 2 : layout.span / 2) + (side === 'left' ? -beamExtension : beamExtension), diff --git a/packages/nodes/src/lean-to-extension/definition.test.ts b/packages/nodes/src/lean-to-extension/definition.test.ts index 5eefb9be91..f1d87652e7 100644 --- a/packages/nodes/src/lean-to-extension/definition.test.ts +++ b/packages/nodes/src/lean-to-extension/definition.test.ts @@ -5,6 +5,7 @@ import { type LeanToExtensionNode, LeanToExtensionNode as LeanToExtensionNodeSchema, type LinearResizeHandle, + WallNode, } from '@pascal-app/core' import { leanToExtensionDefinition } from './definition' import { resolveLeanToLayout } from './layout' @@ -28,7 +29,7 @@ function handles(): HandleDescriptor[] { } function linearHandle( - axis: 'x' | 'z', + axis: 'x' | 'y' | 'z', anchor: 'min' | 'max', ): LinearResizeHandle { const handle = handles().find( @@ -43,6 +44,21 @@ function spanHandle(anchor: 'min' | 'max'): LinearResizeHandle { + return linearHandle('y', 'min') +} + +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 +} + describe('lean-to extension span handles', () => { test('exposes right and left span arrows on the whole extension', () => { expect(spanHandle('min').placement.rotationY?.(node(), undefined as never)).toBe(0) @@ -65,6 +81,14 @@ describe('lean-to extension span handles', () => { ]) }) + test('hides host-controlled span and height arrows on a closed conical loop', () => { + const circular = node({ hostKind: 'conical-roof' }) + + expect(spanHandle('min').visible?.(circular, undefined as never)).toBe(false) + expect(spanHandle('max').visible?.(circular, undefined as never)).toBe(false) + expect(heightHandle().visible?.(circular, undefined as never)).toBe(false) + }) + test('places projection arrow at the same low roof edge height', () => { const leanTo = node() const layout = resolveLeanToLayout(leanTo) @@ -76,6 +100,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 +158,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 +233,90 @@ describe('lean-to extension span handles', () => { children: [], }, } as unknown as Record + const sceneApi = { get: (id: string) => nodes[id], nodes: () => nodes } as never - const preview = new Map( - spanHandle('min').previewOverrides?.(leanTo, 6, { nodes: () => nodes } as never) ?? [], - ) + const preview = new Map(spanHandle('min').previewOverrides?.(leanTo, 6, sceneApi) ?? []) expect(preview.get('rseg_test' as never)).toMatchObject({ roofType: 'shed', width: 6 + leanTo.leftOverhang + leanTo.rightOverhang, }) }) + + test('previews the managed roof at the in-flight wall-side height', () => { + const leanTo = node({ children: ['roof_test' as never], highEdgeHeight: 2.8 }) + const nodes = { + [leanTo.id]: leanTo, + roof_test: { + id: 'roof_test', + type: 'roof', + parentId: leanTo.id, + metadata: { managedByLeanTo: leanTo.id, leanToRole: 'roof' }, + children: ['rseg_test'], + }, + rseg_test: { + id: 'rseg_test', + type: 'roof-segment', + parentId: 'roof_test', + metadata: { managedByLeanTo: leanTo.id, leanToRole: 'roof-segment' }, + children: [], + }, + } as unknown as Record + const sceneApi = { get: (id: string) => nodes[id], nodes: () => nodes } as never + + const initialPreview = new Map(heightHandle().previewOverrides?.(leanTo, 2.8, sceneApi) ?? []) + const raisedPreview = new Map(heightHandle().previewOverrides?.(leanTo, 3.4, sceneApi) ?? []) + const initialPosition = initialPreview.get('rseg_test' as never)?.position + const raisedPosition = raisedPreview.get('rseg_test' as never)?.position + + expect(initialPosition).toBeDefined() + expect(raisedPosition?.[1] - initialPosition?.[1]).toBeCloseTo(0.6) + }) + + test('connects the high edge with an adjacent lean-to', () => { + const wall = WallNode.parse({ + id: 'wall_left', + parentId: 'level_test', + start: [0, 0], + end: [5, 0], + }) + const adjacentWall = WallNode.parse({ + id: 'wall_right', + parentId: 'level_test', + start: [5, 0], + end: [10, 0], + }) + const moving = node({ + parentId: wall.id, + position: [4, 0, 0.05], + span: 2, + highEdgeHeight: 2.8, + leftOverhang: 0, + rightOverhang: 0, + }) + const adjacent = LeanToExtensionNodeSchema.parse({ + id: 'leanto_adjacent', + parentId: adjacentWall.id, + position: [1, 0, 0.05], + span: 2, + highEdgeHeight: 3.4, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wall.id]: wall, + [adjacentWall.id]: adjacentWall, + [moving.id]: moving, + [adjacent.id]: adjacent, + } as Record + const sceneApi = { + get: (id: string) => nodes[id], + nodes: () => nodes, + } as never + + const snap = heightHandle().connectionSnap + expect(snap?.(moving, 3.34, sceneApi)).toBe(3.4) + expect(snap?.(moving, 3.6, sceneApi)).toBe(3.6) + expect(snap?.({ ...moving, position: [2, 0, 0.05] }, 3.34, sceneApi)).toBe(3.34) + }) }) diff --git a/packages/nodes/src/lean-to-extension/definition.ts b/packages/nodes/src/lean-to-extension/definition.ts index af914d0f72..3c178fc184 100644 --- a/packages/nodes/src/lean-to-extension/definition.ts +++ b/packages/nodes/src/lean-to-extension/definition.ts @@ -1,29 +1,28 @@ -import type { - AnyNode, - AnyNodeId, - HandleDescriptor, - NodeDefinition, - SceneApi, - WallNode, +import { + type AnyNodeId, + findLevelAncestorId, + type HandleDescriptor, + type NodeDefinition, + 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 { 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 +31,10 @@ 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 resolveHostWall(node: LeanToExtensionNode, sceneApi: SceneApi): WallNode | null { if (!node.parentId) return null @@ -40,6 +42,100 @@ 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 { + 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 +145,13 @@ function highEdgeHeightHandle(): HandleDescriptor { min: 0.8, max: 10, currentValue: (node) => node.highEdgeHeight, - magneticSnap: (node, newValue, sceneApi) => { - const wall = resolveHostWall(node, sceneApi) - if (!wall) return newValue - const attachment = resolveLeanToRoofAttachment( - { ...node, highEdgeHeight: newValue }, - wall, - sceneApi.nodes(), - ) - return attachment && - Math.abs(attachment.highEdgeHeight - newValue) <= ROOF_EDGE_SNAP_TOLERANCE - ? attachment.highEdgeHeight - : newValue - }, - apply: (node, newValue, sceneApi) => { - const wall = resolveHostWall(node, sceneApi) - const attachment = wall - ? resolveLeanToRoofAttachment({ ...node, highEdgeHeight: newValue }, wall, sceneApi.nodes()) - : null - if ( - attachment && - Math.abs(attachment.highEdgeHeight - newValue) <= ROOF_EDGE_SNAP_TOLERANCE - ) { - const connected = applyLeanToRoofAttachment(node, attachment) - return { - highEdgeHeight: connected.highEdgeHeight, - lowEdgeHeight: connected.lowEdgeHeight, - connectionMode: connected.connectionMode, - hostRoofId: connected.hostRoofId, - hostRoofSegmentId: connected.hostRoofSegmentId, - hostRoofEdge: connected.hostRoofEdge, - hostRoofEdgeRange: connected.hostRoofEdgeRange, - connectionInset: connected.connectionInset, - span: connected.span, - position: connected.position, - roofThickness: connected.roofThickness, - shingleThickness: connected.shingleThickness, - } - } - return { - ...deriveLeanToResizePatch(node, { highEdgeHeight: newValue }), - connectionMode: 'manual', - hostRoofId: undefined, - hostRoofSegmentId: undefined, - hostRoofEdge: undefined, - hostRoofEdgeRange: undefined, - connectionInset: 0, - } - }, + connectionSnap: resolveHighEdgeConnectionSnap, + apply: highEdgeHeightPatch, + previewOverrides: (node, newValue, sceneApi) => + leanToManagedPreviewOverrides(node, highEdgeHeightPatch(node, newValue, sceneApi), sceneApi), + visible: (node) => node.hostKind !== 'conical-roof', + onDrag: publishAdjacentHeightGuide, + onDragEnd: (node) => clearStructuralElevationGuide(node.id), placement: { position: (node) => [0, node.highEdgeHeight + HEIGHT_HANDLE_OFFSET, 0], }, @@ -104,81 +159,78 @@ 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 return { @@ -199,11 +251,28 @@ function spanHandle(side: 'left' | 'right'): HandleDescriptor { + const wall = resolveHostWall(node, sceneApi) + return wall + ? resolveLeanToSpanResizeProposal({ node, wall, rawSpan: 100, side, tolerance: 0 }).span + : 100 + }, currentValue: (node) => node.span, - apply: (node, span) => spanPatch(node, span, side), + connectionSnap: (node, span, sceneApi) => { + const wall = resolveHostWall(node, sceneApi) + if (!wall) return span + return resolveLeanToSpanResizeProposal({ + node, + wall, + rawSpan: span, + side, + edgeSnapTargets: resolveLeanToEdgeSnapTargets(node, wall, sceneApi.nodes()), + }).span + }, + apply: (node, span, sceneApi) => spanPatch(node, span, side, sceneApi), previewOverrides: (node, span, sceneApi) => - leanToManagedPreviewOverrides(node, spanPatch(node, span, side), sceneApi), + leanToManagedPreviewOverrides(node, spanPatch(node, span, side, sceneApi), sceneApi), + visible: (node) => node.hostKind !== 'conical-roof', placement: { position: (node) => { const layout = resolveLeanToLayout(node) @@ -219,7 +288,10 @@ function spanHandle(side: 'left' | 'right'): HandleDescriptor[] = [highEdgeHeightHandle()] +const leanToExtensionHandles: HandleDescriptor[] = [ + highEdgeHeightHandle(), + pitchHandle(), +] leanToExtensionHandles.push({ kind: 'linear-resize', axis: 'z', @@ -243,7 +315,7 @@ leanToExtensionHandles.push(spanHandle('right'), spanHandle('left')) export const leanToExtensionDefinition: NodeDefinition = { kind: 'lean-to-extension', - schemaVersion: 7, + schemaVersion: 8, schema: LeanToExtensionNode, category: 'structure', snapProfile: 'structural', @@ -287,12 +359,13 @@ export const leanToExtensionDefinition: NodeDefinition import('./preview'), tool: () => import('./tool'), toolHints: [ - { key: 'Left click', label: 'Attach lean-to extension to wall' }, + { key: 'Left click', label: 'Attach to wall or conical roof base' }, { key: 'Esc', label: 'Cancel' }, ], presentation: { label: 'Lean-to Extension', - description: 'An open mono-pitch roof attached to a wall and supported by a pillar row.', + description: + 'An open mono-pitch roof attached to a wall or wrapped around a conical roof base.', icon: { kind: 'url', src: '/icons/lean-to-extension.webp' }, paletteSection: 'structure', paletteGroup: 'roof-features', @@ -300,6 +373,6 @@ export const leanToExtensionDefinition: NodeDefinition = 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 ? 0 : getSegmentGridStep() const value = Math.max(0.5, step > 0 ? snapScalar(raw, step) : raw) lastPatch = dimension === 'projection' ? { projection: value, ...deriveLeanToResizePatch(node, { projection: value }) } - : { - span: value, - autoSpan: false, - position: [ - initialPosition[0] + (side * (value - initialValue)) / 2, - initialPosition[1], - initialPosition[2], - ], - } + : (() => { + const proposal = resolveLeanToSpanResizeProposal({ + node, + wall, + rawSpan: value, + side: side > 0 ? 'right' : 'left', + edgeSnapTargets: modifiers.altKey + ? [] + : resolveLeanToEdgeSnapTargets(node, wall, nodes), + }) + return { + span: proposal.span, + autoSpan: false, + position: proposal.position, + ...(proposal.target + ? { + highEdgeHeight: proposal.highEdgeHeight, + lowEdgeHeight: proposal.lowEdgeHeight, + pitch: proposal.pitch, + } + : {}), + } + })() useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch) sceneApi.markDirty(node.id as AnyNodeId) }, 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..257dd08b6d --- /dev/null +++ b/packages/nodes/src/lean-to-extension/floorplan-move.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + LeanToExtensionNode, + nodeRegistry, + registerNode, + useLiveNodeOverrides, + WallNode, +} from '@pascal-app/core' +import { useEditor, useInteractionScope } from '@pascal-app/editor' +import { leanToExtensionDefinition } from './definition' +import { leanToFloorplanMoveTarget } from './floorplan-move' + +afterEach(() => { + useInteractionScope.getState().end() + useLiveNodeOverrides.getState().clearAll() +}) + +describe('lean-to floorplan move snapping', () => { + 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..9ea79a24eb 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-move.ts +++ b/packages/nodes/src/lean-to-extension/floorplan-move.ts @@ -8,8 +8,9 @@ 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 { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' // Arc-length along the wall centerline to the point on it nearest the @@ -53,9 +54,12 @@ 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 const rawLocalX = isCurvedWall(wall) @@ -68,39 +72,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..980f670ec7 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx +++ b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx @@ -13,6 +13,7 @@ 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 } from './conical-host' import { leanToFacetCount } from './geometry' import { resolveLeanToSpanArc, resolveLeanToWallPlacement } from './layout' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' @@ -27,6 +28,10 @@ import { import type { LeanToExtensionNode } from './schema' type PlanPoint = [number, number] +type PlanTarget = { + node: LeanToExtensionNode + conicalHost?: ConicalLeanToPlanHost +} function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number): PlanPoint | null { const matrix = group.getScreenCTM() @@ -42,8 +47,8 @@ const FloorplanLeanToExtensionTool = ({ selectNode, }: FloorplanToolContext) => { const groupRef = useRef(null) - const targetRef = useRef(null) - const [target, setTarget] = useState(null) + const targetRef = useRef(null) + const [target, setTarget] = useState(null) const clearTarget = useCallback(() => { targetRef.current = null @@ -65,15 +70,13 @@ 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, - ) + const nodes = sceneApi.nodes() as Record + const conicalHost = findConicalLeanToHostInPlan(point, nodes, activeLevelId) + if (conicalHost) return { node: conicalHost.node, conicalHost } + const hit = findClosestWallInPlan(point, nodes, 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) @@ -85,7 +88,7 @@ const FloorplanLeanToExtensionTool = ({ wallPlacement.position[0], ) const node = resolveLeanToEndAbutments(attachedNode, hit.wall, nodes) - return leanToPlacementConflicts(node, hit.wall, nodes).length === 0 ? node : null + return leanToPlacementConflicts(node, hit.wall, nodes).length === 0 ? { node } : null } const update = (event: PointerEvent) => { consume(event) @@ -99,8 +102,9 @@ const FloorplanLeanToExtensionTool = ({ const commit = (event: MouseEvent) => { if (event.button !== 0) return consume(event) - const node = resolveEvent(event) ?? targetRef.current - if (!node) return + const resolved = resolveEvent(event) ?? targetRef.current + if (!resolved) return + const { node } = resolved const nodes = sceneApi.nodes() as Record const assembly = createLeanToAssembly(node, resolveLeanToHostRoof(node, nodes), nodes) sceneApi.createMany?.([ @@ -141,15 +145,52 @@ const FloorplanLeanToExtensionTool = ({ }, [activeLevelId, clearTarget, finishTool, sceneApi, selectNode]) if (!activeLevelId) return null - const wall = target?.parentId ? sceneApi.get(target.parentId as AnyNodeId) : null - if (!(target && wall?.type === 'wall')) return + if (target?.conicalHost) { + const { center, segment } = target.conicalHost + const innerRadius = Math.max(0.01, segment.width / 2 - target.node.highOverhang) + const outerRadius = segment.width / 2 + target.node.projection + target.node.lowOverhang + const points: [number, number][] = [] + const facets = leanToFacetCount(target.node) + for (let index = 0; index <= facets; index++) { + const angle = (index / facets) * Math.PI * 2 + points.push([ + center[0] + Math.sin(angle) * innerRadius, + center[1] + Math.cos(angle) * innerRadius, + ]) + } + for (let index = facets; index >= 0; index--) { + const angle = (index / facets) * Math.PI * 2 + points.push([ + center[0] + Math.sin(angle) * outerRadius, + center[1] + Math.cos(angle) * outerRadius, + ]) + } + return ( + + point.join(',')).join(' ')} + stroke="#0ea5e9" + 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 && wall?.type === 'wall')) return - const sign = Math.cos(target.rotation[1]) >= 0 ? 1 : -1 + const sign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 // Recompute the local arc from the final placed span/position so the preview // footprint bends the same way reconciliation will store it. - const spanArc = resolveLeanToSpanArc(wall, target) + const spanArc = resolveLeanToSpanArc(wall, node) const previewNode = { - ...target, + ...node, spanArcCenterZ: spanArc?.centerZ, spanArcRadius: spanArc?.radius, } @@ -163,14 +204,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 +220,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 +240,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][] = [] diff --git a/packages/nodes/src/lean-to-extension/floorplan.test.ts b/packages/nodes/src/lean-to-extension/floorplan.test.ts index 80c1073851..ecd1098463 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.test.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.test.ts @@ -3,8 +3,11 @@ import { type GeometryContext, getWallCurveFrameAt, getWallCurveLength, + RoofNode, + RoofSegmentNode, WallNode, } from '@pascal-app/core' +import { resolveConicalLeanToPlacement } from './conical-host' import { buildLeanToExtensionFloorplan } from './floorplan' import { resolveLeanToWallPlacement } from './layout' @@ -37,4 +40,40 @@ describe('curved lean-to floorplan', () => { expect(roof.points[0]?.[0]).toBeCloseTo(frame.point.x + frame.normal.x * node.position[2], 3) expect(roof.points[0]?.[1]).toBeCloseTo(frame.point.y + frame.normal.y * node.position[2], 3) }) + + test('draws a closed canopy around a conical host', () => { + const roof = RoofNode.parse({ + id: 'roof_conical_floorplan', + position: [2, 0, 3], + children: ['rseg_conical_floorplan'], + }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_conical_floorplan', + parentId: roof.id, + roofType: 'conical', + position: [1, 0, 0], + width: 8, + depth: 8, + wallHeight: 3, + }) + const node = resolveConicalLeanToPlacement(segment)! + const geometry = buildLeanToExtensionFloorplan(node, { + children: [], + parent: segment, + resolve: (id) => (id === roof.id ? roof : undefined), + siblings: [], + } as GeometryContext) + + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + const roofBand = geometry.children.find((child) => child.kind === 'polygon') + expect(roofBand?.kind).toBe('polygon') + if (roofBand?.kind !== 'polygon') return + const xs = roofBand.points.map((point) => point[0]) + const zs = roofBand.points.map((point) => point[1]) + expect(Math.min(...xs)).toBeCloseTo(3 - 6.75, 2) + expect(Math.max(...xs)).toBeCloseTo(3 + 6.75, 2) + expect(Math.min(...zs)).toBeCloseTo(3 - 6.75, 2) + expect(Math.max(...zs)).toBeCloseTo(3 + 6.75, 2) + }) }) diff --git a/packages/nodes/src/lean-to-extension/floorplan.ts b/packages/nodes/src/lean-to-extension/floorplan.ts index b56f4c814f..18341aa127 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.ts @@ -1,4 +1,5 @@ import { + type AnyNodeId, type FloorplanGeometry, type FloorplanPoint, type GeometryContext, @@ -6,16 +7,126 @@ import { getWallCurveLength, isCurvedWall, type LeanToExtensionNode, + type RoofNode, + type RoofSegmentNode, type WallNode, } from '@pascal-app/core' import { bendLocalPoint, isCurvedLeanTo } from './arc' import { leanToFacetCount } from './geometry' import { resolveLeanToLayout } from './layout' +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 x of layout.postXs) { + const [postX, postZ] = toWorld(x, layout.beamZ) + children.push({ + kind: 'rect', + x: postX - node.postWidth / 2, + y: postZ - node.postDepth / 2, + width: node.postWidth, + height: node.postDepth, + fill: stroke, + stroke, + strokeWidth: 1, + vectorEffect: 'non-scaling-stroke', + }) + } + if (selected) { + const 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 } +} + 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) + } const wall = ctx.parent as WallNode | null if (wall?.type !== 'wall') return null diff --git a/packages/nodes/src/lean-to-extension/layout.test.ts b/packages/nodes/src/lean-to-extension/layout.test.ts index 9530c2979a..a775c576aa 100644 --- a/packages/nodes/src/lean-to-extension/layout.test.ts +++ b/packages/nodes/src/lean-to-extension/layout.test.ts @@ -12,7 +12,9 @@ import { resolveLeanToEdgeSnapTargets, resolveLeanToLayout, resolveLeanToMoveCenterX, + resolveLeanToMoveProposal, resolveLeanToParentPose, + resolveLeanToSpanResizeProposal, resolveLeanToWallPlacement, resolveLeanToWallSurfaceHit, } from './layout' @@ -201,7 +203,7 @@ describe('lean-to wall placement', () => { const adjacent = LeanToExtensionNode.parse({ id: 'leanto_right', parentId: adjacentWall.id, - position: [1.2, 0, 0.05], + position: [1, 0, 0.05], span: 2, leftOverhang: 0, rightOverhang: 0, @@ -217,13 +219,197 @@ describe('lean-to wall placement', () => { resolveLeanToMoveCenterX( moving, wall, - 4.1, + 3.9, 0, resolveLeanToEdgeSnapTargets(moving, wall, nodes), ), ).toBe(4) }) + test('aligns the moving roof height when its edge magnetically snaps to a neighbor', () => { + const wall = WallNode.parse({ + id: 'wall_left', + parentId: 'level_test', + start: [0, 0], + end: [5, 0], + }) + const adjacentWall = WallNode.parse({ + id: 'wall_right', + parentId: 'level_test', + start: [5, 0], + end: [10, 0], + }) + const moving = LeanToExtensionNode.parse({ + id: 'leanto_left', + parentId: wall.id, + position: [2, 0, 0.05], + span: 2, + highEdgeHeight: 2.8, + leftOverhang: 0, + rightOverhang: 0, + }) + const adjacent = LeanToExtensionNode.parse({ + id: 'leanto_right', + parentId: adjacentWall.id, + position: [1, 0, 0.05], + span: 2, + highEdgeHeight: 3.4, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wall.id]: wall, + [adjacentWall.id]: adjacentWall, + [moving.id]: moving, + [adjacent.id]: adjacent, + } as Record + + const proposal = resolveLeanToMoveProposal({ + node: moving, + wall, + rawLocalX: 3.9, + rawHighEdgeHeight: 3, + edgeSnapTargets: resolveLeanToEdgeSnapTargets(moving, wall, nodes), + }) + + expect(proposal.centerX).toBe(4) + expect(proposal.highEdgeHeight).toBe(3.4) + expect(proposal.lowEdgeHeight - moving.lowEdgeHeight).toBeCloseTo(0.6) + }) + + test('stops a span resize at the host wall end', () => { + const wall = WallNode.parse({ start: [0, 0], end: [10, 0] }) + const leanTo = LeanToExtensionNode.parse({ + parentId: wall.id, + position: [4, 0, 0.05], + span: 4, + leftOverhang: 0.2, + rightOverhang: 0.2, + }) + + const proposal = resolveLeanToSpanResizeProposal({ + node: leanTo, + wall, + rawSpan: 7.65, + side: 'right', + }) + + expect(proposal.span).toBeCloseTo(7.8) + expect(proposal.position[0]).toBeCloseTo(5.9) + expect(proposal.position[0] + proposal.span / 2 + leanTo.rightOverhang).toBeCloseTo(10) + }) + + test('fits a resized span to its neighbor and adopts the same roof plane', () => { + const wall = WallNode.parse({ + id: 'wall_span_left', + parentId: 'level_test', + start: [0, 0], + end: [5, 0], + }) + const adjacentWall = WallNode.parse({ + id: 'wall_span_right', + parentId: 'level_test', + start: [5, 0], + end: [10, 0], + }) + const moving = LeanToExtensionNode.parse({ + id: 'leanto_span_left', + parentId: wall.id, + position: [2, 0, 0.05], + span: 2, + highEdgeHeight: 2.8, + pitch: 8, + leftOverhang: 0, + rightOverhang: 0, + }) + const adjacent = LeanToExtensionNode.parse({ + id: 'leanto_span_right', + parentId: adjacentWall.id, + position: [1, 0, 0.05], + span: 2, + highEdgeHeight: 3.4, + pitch: 12, + leftOverhang: 0, + rightOverhang: 0, + }) + const nodes = { + [wall.id]: wall, + [adjacentWall.id]: adjacentWall, + [moving.id]: moving, + [adjacent.id]: adjacent, + } as Record + + const proposal = resolveLeanToSpanResizeProposal({ + node: moving, + wall, + rawSpan: 3.85, + side: 'right', + edgeSnapTargets: resolveLeanToEdgeSnapTargets(moving, wall, nodes), + }) + + expect(proposal.span).toBe(4) + expect(proposal.position[0]).toBe(3) + expect(proposal.highEdgeHeight).toBe(3.4) + expect(proposal.pitch).toBe(12) + expect(proposal.lowEdgeHeight).toBeCloseTo( + proposal.highEdgeHeight - moving.projection * Math.tan((proposal.pitch * Math.PI) / 180), + ) + expect(proposal.target?.nodeId).toBe(adjacent.id) + }) + + test('aligns a straight span with a curved roof at their tangent wall end', () => { + const curvedWall = WallNode.parse({ + id: 'wall_resize_curved', + parentId: 'level_test', + start: [0, 0], + end: [6, 0], + curveOffset: 1, + }) + const straightWall = WallNode.parse({ + id: 'wall_resize_tangent', + parentId: 'level_test', + start: [6, 0], + end: [10.8, 3.6], + }) + const curvedLength = getWallCurveLength(curvedWall) + const straightLength = getWallCurveLength(straightWall) + const curved = LeanToExtensionNode.parse({ + id: 'leanto_resize_curved', + parentId: curvedWall.id, + position: [curvedLength / 2, 0, 0.05], + span: curvedLength - 0.3, + highEdgeHeight: 3.5, + pitch: 14, + }) + const straight = LeanToExtensionNode.parse({ + id: 'leanto_resize_tangent', + parentId: straightWall.id, + position: [3.75, 0, 0.05], + span: 4.2, + highEdgeHeight: 2.8, + pitch: 8, + }) + const nodes = { + [curvedWall.id]: curvedWall, + [straightWall.id]: straightWall, + [curved.id]: curved, + [straight.id]: straight, + } as Record + + const proposal = resolveLeanToSpanResizeProposal({ + node: straight, + wall: straightWall, + rawSpan: straightLength - 0.45, + side: 'left', + edgeSnapTargets: resolveLeanToEdgeSnapTargets(straight, straightWall, nodes), + }) + + expect(proposal.position[0] - proposal.span / 2 - straight.leftOverhang).toBeCloseTo(0) + expect(proposal.highEdgeHeight).toBe(3.5) + expect(proposal.pitch).toBe(14) + expect(proposal.target?.nodeId).toBe(curved.id) + }) + test('keeps existing roof data unchanged when parsed with the extended node union', () => { const existingRoof = RoofNode.parse({ children: [], diff --git a/packages/nodes/src/lean-to-extension/layout.ts b/packages/nodes/src/lean-to-extension/layout.ts index 6b274d190e..0d3534bc03 100644 --- a/packages/nodes/src/lean-to-extension/layout.ts +++ b/packages/nodes/src/lean-to-extension/layout.ts @@ -11,11 +11,13 @@ import { } from '@pascal-app/core' import { EAVE_TUCK_INWARD } from '../gutter/eave-snap' import { type LeanToArcFrame, leanToArcFrameAtLocalX } from './arc' +import { isClosedLoopLeanTo } from './conical-host' export const MIN_LEAN_TO_POST_HEIGHT = 0.2 export const MIN_LEAN_TO_WALL_LENGTH = 0.6 export const LEAN_TO_EXTENSION_GEOMETRY_REVISION = 8 -const LEAN_TO_EDGE_SNAP_TOLERANCE = 0.25 +export const LEAN_TO_EDGE_SNAP_TOLERANCE = 0.25 +export const LEAN_TO_HEIGHT_SNAP_TOLERANCE = 0.15 const CURVED_INNER_EDGE_CLEARANCE = 0.15 export type LeanToLayout = { @@ -138,18 +140,28 @@ export function resolveLeanToLayout(node: LeanToExtensionNode): LeanToLayout { const beamCenterY = beamTop - node.beamHeight / 2 const postHeight = Math.max(MIN_LEAN_TO_POST_HEIGHT, beamCenterY - node.beamHeight / 2) const usablePostSpan = Math.max(0.1, span - 2 * Math.max(0, node.postInset)) + const closedLoop = isClosedLoopLeanTo(node) const postCount = node.postLayoutMode === 'target-spacing' - ? Math.max(2, Math.min(20, Math.ceil(usablePostSpan / node.postSpacing) + 1)) + ? Math.max( + closedLoop ? 3 : 2, + Math.min(20, Math.ceil(usablePostSpan / node.postSpacing) + (closedLoop ? 0 : 1)), + ) : node.postCount - const postXs = evenlySpacedXs(span, postCount, node.postInset) - const beamSpan = Math.max( - node.postWidth, - (postXs.at(-1) ?? 0) - (postXs[0] ?? 0) + node.postWidth, - ) + const postXs = closedLoop + ? evenlySpacedLoopXs(span, postCount) + : evenlySpacedXs(span, postCount, node.postInset) + const beamSpan = closedLoop + ? span + : Math.max(node.postWidth, (postXs.at(-1) ?? 0) - (postXs[0] ?? 0) + node.postWidth) const usableRafterSpan = Math.max(0.1, span - 2 * Math.max(0, node.rafterEndInset)) - const rafterCount = Math.max(2, Math.ceil(usableRafterSpan / node.rafterSpacing) + 1) - const rafterXs = evenlySpacedXs(span, rafterCount, node.rafterEndInset) + const rafterCount = Math.max( + closedLoop ? 3 : 2, + Math.ceil(usableRafterSpan / node.rafterSpacing) + (closedLoop ? 0 : 1), + ) + const rafterXs = closedLoop + ? evenlySpacedLoopXs(span, rafterCount) + : evenlySpacedXs(span, rafterCount, node.rafterEndInset) return { span, @@ -210,24 +222,161 @@ export function resolveLeanToMoveCenterX( snapStep = 0, edgeSnapTargets: readonly LeanToEdgeSnapTarget[] = [], ): number { + return resolveLeanToMoveProposal({ + node, + wall, + rawLocalX, + rawHighEdgeHeight: node.highEdgeHeight, + snapStep, + edgeSnapTargets, + }).centerX +} + +export type LeanToMoveProposal = { + centerX: number + highEdgeHeight: number + lowEdgeHeight: number +} + +export function resolveLeanToMoveProposal({ + node, + wall, + rawLocalX, + rawHighEdgeHeight, + snapStep = 0, + edgeSnapTargets = [], +}: { + node: LeanToExtensionNode + wall: WallNode + rawLocalX: number + rawHighEdgeHeight: number + snapStep?: number + edgeSnapTargets?: readonly LeanToEdgeSnapTarget[] +}): LeanToMoveProposal { const wallLength = getWallCurveLength(wall) const snapped = snapStep > 0 ? Math.round(rawLocalX / snapStep) * snapStep : rawLocalX const min = node.span / 2 + Math.max(0, node.leftOverhang) const max = wallLength - node.span / 2 - Math.max(0, node.rightOverhang) - if (max < min) return wallLength / 2 + const rawHeightDelta = rawHighEdgeHeight - node.highEdgeHeight + if (max < min) { + return { + centerX: wallLength / 2, + highEdgeHeight: rawHighEdgeHeight, + lowEdgeHeight: node.lowEdgeHeight + rawHeightDelta, + } + } const clamped = Math.max(min, Math.min(max, snapped)) - return snapLeanToMoveCenterToEdges(node, clamped, min, max, edgeSnapTargets) + const edgeSnap = snapLeanToMoveCenterToEdges(node, clamped, min, max, edgeSnapTargets) + const highEdgeHeight = edgeSnap ? edgeSnap.target.roofEdgeY - node.position[1] : rawHighEdgeHeight + return { + centerX: edgeSnap?.centerX ?? clamped, + highEdgeHeight, + lowEdgeHeight: node.lowEdgeHeight + highEdgeHeight - node.highEdgeHeight, + } } export type LeanToEdgeSnapTarget = { leftEdgeX: number rightEdgeX: number + roofEdgeY: number + pitch?: number + nodeId?: AnyNodeId + anchor?: readonly [number, number] +} + +export type LeanToHeightSnapMatch = { + highEdgeHeight: number + target: LeanToEdgeSnapTarget } function leanToEdgeSnapTarget(node: LeanToExtensionNode): LeanToEdgeSnapTarget { return { leftEdgeX: node.position[0] - node.span / 2 - Math.max(0, node.leftOverhang), rightEdgeX: node.position[0] + node.span / 2 + Math.max(0, node.rightOverhang), + roofEdgeY: node.position[1] + node.highEdgeHeight, + pitch: node.pitch, + } +} + +export type LeanToSpanResizeSide = 'left' | 'right' + +export type LeanToSpanResizeProposal = { + span: number + position: [number, number, number] + highEdgeHeight: number + lowEdgeHeight: number + pitch: number + target: LeanToEdgeSnapTarget | null +} + +export function resolveLeanToSpanResizeProposal({ + node, + wall, + rawSpan, + side, + edgeSnapTargets = [], + tolerance = LEAN_TO_EDGE_SNAP_TOLERANCE, +}: { + node: LeanToExtensionNode + wall: WallNode + rawSpan: number + side: LeanToSpanResizeSide + edgeSnapTargets?: readonly LeanToEdgeSnapTarget[] + tolerance?: number +}): LeanToSpanResizeProposal { + const wallLength = getWallCurveLength(wall) + const visualSign = side === 'right' ? 1 : -1 + const wallSign = Math.cos(node.rotation[1]) >= 0 ? visualSign : -visualSign + const fixedStructuralEdge = node.position[0] - wallSign * (node.span / 2) + const draggedOverhang = Math.max(0, wallSign > 0 ? node.rightOverhang : node.leftOverhang) + const maximumSpan = Math.max( + 0.5, + wallSign > 0 + ? wallLength - fixedStructuralEdge - draggedOverhang + : fixedStructuralEdge - draggedOverhang, + ) + const boundedSpan = Math.max(0.5, Math.min(maximumSpan, rawSpan)) + const centerX = fixedStructuralEdge + wallSign * (boundedSpan / 2) + const draggedRoofEdge = centerX + wallSign * (boundedSpan / 2 + draggedOverhang) + const wallEdgeX = wallSign > 0 ? wallLength : 0 + let best: { edgeX: number; distance: number; target: LeanToEdgeSnapTarget | null } = { + edgeX: wallEdgeX, + distance: Math.abs(draggedRoofEdge - wallEdgeX), + target: null, + } + + for (const target of edgeSnapTargets) { + const edgeX = wallSign > 0 ? target.leftEdgeX : target.rightEdgeX + const distance = Math.abs(draggedRoofEdge - edgeX) + if (distance < best.distance || (Math.abs(distance - best.distance) <= 1e-9 && !best.target)) { + best = { edgeX, distance, target } + } + } + + const snapped = best.distance <= tolerance + const span = snapped + ? Math.max(0.5, Math.min(maximumSpan, boundedSpan + wallSign * (best.edgeX - draggedRoofEdge))) + : boundedSpan + const position: [number, number, number] = [ + fixedStructuralEdge + wallSign * (span / 2), + node.position[1], + node.position[2], + ] + const target = snapped ? best.target : null + const pitch = target?.pitch ?? node.pitch + const highEdgeHeight = target ? target.roofEdgeY - node.position[1] : node.highEdgeHeight + + return { + span, + position, + highEdgeHeight, + lowEdgeHeight: leanToLowEdgeHeight({ + highEdgeHeight, + pitch, + projection: node.projection, + }), + pitch, + target, } } @@ -237,10 +386,10 @@ function snapLeanToMoveCenterToEdges( min: number, max: number, targets: readonly LeanToEdgeSnapTarget[], -): number { +): { centerX: number; target: LeanToEdgeSnapTarget } | null { const movingLeft = centerX - node.span / 2 - Math.max(0, node.leftOverhang) const movingRight = centerX + node.span / 2 + Math.max(0, node.rightOverhang) - let best: { centerX: number; distance: number } | null = null + let best: { centerX: number; distance: number; target: LeanToEdgeSnapTarget } | null = null for (const target of targets) { const leftToRight = Math.abs(movingLeft - target.rightEdgeX) @@ -249,7 +398,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 +409,51 @@ function snapLeanToMoveCenterToEdges( if (snappedCenter >= min && snappedCenter <= max) { best = !best || rightToLeft < best.distance - ? { centerX: snappedCenter, distance: rightToLeft } + ? { centerX: snappedCenter, distance: rightToLeft, target } : best } } } - return best?.centerX ?? centerX + return best ? { centerX: best.centerX, target: best.target } : null +} + +export function resolveLeanToHighEdgeHeightSnap( + node: LeanToExtensionNode, + rawHighEdgeHeight: number, + targets: readonly LeanToEdgeSnapTarget[], + tolerance = LEAN_TO_HEIGHT_SNAP_TOLERANCE, +): LeanToHeightSnapMatch | null { + const movingLeft = node.position[0] - node.span / 2 - Math.max(0, node.leftOverhang) + const movingRight = node.position[0] + node.span / 2 + Math.max(0, node.rightOverhang) + let best: { heightDelta: number; edgeDistance: number; target: LeanToEdgeSnapTarget } | null = + null + + for (const target of targets) { + const edgeDistance = Math.min( + Math.abs(movingLeft - target.rightEdgeX), + Math.abs(movingRight - target.leftEdgeX), + ) + if (edgeDistance > LEAN_TO_EDGE_SNAP_TOLERANCE) continue + + const targetHeight = target.roofEdgeY - node.position[1] + const heightDelta = Math.abs(targetHeight - rawHighEdgeHeight) + if (heightDelta > tolerance) continue + if ( + !best || + heightDelta < best.heightDelta - 1e-9 || + (Math.abs(heightDelta - best.heightDelta) <= 1e-9 && edgeDistance < best.edgeDistance) + ) { + best = { heightDelta, edgeDistance, target } + } + } + + return best + ? { + highEdgeHeight: best.target.roofEdgeY - node.position[1], + target: best.target, + } + : null } export function resolveLeanToEdgeSnapTargets( @@ -274,24 +461,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 +535,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 +557,12 @@ function evenlySpacedXs(span: number, count: number, requestedInset: number): nu return Array.from({ length: resolvedCount }, (_, index) => first + index * step) } +function evenlySpacedLoopXs(span: number, count: number): number[] { + const resolvedCount = Math.max(3, Math.round(count)) + const step = span / resolvedCount + return Array.from({ length: resolvedCount }, (_, index) => -span / 2 + index * step) +} + export function resolveLeanToWallPlacement( wall: WallNode, rawLocalX: number, diff --git a/packages/nodes/src/lean-to-extension/linear-joint.test.ts b/packages/nodes/src/lean-to-extension/linear-joint.test.ts new file mode 100644 index 0000000000..6b768ad504 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/linear-joint.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + LeanToExtensionNode, + type LeanToExtensionNode as LeanToExtensionNodeType, + WallNode, +} from '@pascal-app/core' +import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' +import { resolveLeanToCornerJoints } from './corner-joint' +import { resolveLeanToLayout } from './layout' + +function linearFixture(overrides: Partial = {}) { + const wall = WallNode.parse({ + id: 'wall_linear_joint', + parentId: 'level_linear_joint', + start: [0, 0], + end: [12, 0], + }) + const left = LeanToExtensionNode.parse({ + id: 'leanto_linear_left', + parentId: wall.id, + position: [2, 0, 0.05], + span: 4, + leftOverhang: 0.15, + rightOverhang: 0.15, + }) + const right = LeanToExtensionNode.parse({ + id: 'leanto_linear_right', + parentId: wall.id, + position: [6.3, 0, 0.05], + span: 4, + leftOverhang: 0.15, + rightOverhang: 0.15, + ...overrides, + }) + const nodes = { + [wall.id]: { ...wall, children: [left.id, right.id] }, + [left.id]: left, + [right.id]: right, + } as Record + return { wall, left, right, nodes } +} + +describe('lean-to linear joints', () => { + test('turns two edge-snapped extensions into one reciprocal structural joint', () => { + const { wall, left, right, nodes } = linearFixture() + const leftJoint = resolveLeanToCornerJoints(left, wall, nodes).right + const rightJoint = resolveLeanToCornerJoints(right, wall, nodes).left + + expect(leftJoint).toMatchObject({ + kind: 'linear', + neighborId: right.id, + neighborSide: 'left', + gutterMitre: 0, + }) + expect(rightJoint).toMatchObject({ + kind: 'linear', + neighborId: left.id, + neighborSide: 'right', + gutterMitre: 0, + }) + expect(Number(leftJoint?.sharedPostOwner) + Number(rightJoint?.sharedPostOwner)).toBe(1) + expect(left.position[0] + (leftJoint?.sharedPostPosition[0] ?? 0)).toBeCloseTo( + right.position[0] + (rightJoint?.sharedPostPosition[0] ?? 0), + 6, + ) + }) + + test('opens the internal roof and gutter ends and generates one joint pillar', () => { + const { left, right, nodes } = linearFixture() + const leftAssembly = createLeanToAssembly(left, undefined, nodes) + const rightAssembly = createLeanToAssembly(right, undefined, nodes) + + expect(leftAssembly.segment.shedOpenEndSides).toContain('right') + expect(rightAssembly.segment.shedOpenEndSides).toContain('left') + expect(leftAssembly.gutter.endCapRight).toBe(false) + expect(rightAssembly.gutter.endCapLeft).toBe(false) + expect(leftAssembly.gutter.endCapLeft).toBe(true) + expect(rightAssembly.gutter.endCapRight).toBe(true) + + const posts = [...leftAssembly.posts, ...rightAssembly.posts] + const sharedPosts = posts.filter((post) => { + const index = managedLeanToPostIndex(post) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }) + const ordinaryCount = + resolveLeanToLayout(left).postXs.length + resolveLeanToLayout(right).postXs.length + expect(sharedPosts).toHaveLength(1) + expect(posts).toHaveLength(ordinaryCount - 1) + }) + + test('does not connect roofs whose edge profiles do not meet', () => { + const heightMismatch = linearFixture({ highEdgeHeight: 3.2 }) + expect( + resolveLeanToCornerJoints(heightMismatch.left, heightMismatch.wall, heightMismatch.nodes) + .right, + ).toBeUndefined() + + const separated = linearFixture({ position: [6.6, 0, 0.05] }) + expect( + resolveLeanToCornerJoints(separated.left, separated.wall, separated.nodes).right, + ).toBeUndefined() + }) + + test('keeps straight snap connectivity independent from corner-miter preference', () => { + const { wall, left, right, nodes } = linearFixture({ autoMiterCorners: false }) + const leftWithoutCornerMitres = { ...left, autoMiterCorners: false } + const resolvedNodes = { + ...nodes, + [left.id]: leftWithoutCornerMitres, + [right.id]: right, + } + + expect( + resolveLeanToCornerJoints(leftWithoutCornerMitres, wall, resolvedNodes).right?.kind, + ).toBe('linear') + }) +}) diff --git a/packages/nodes/src/lean-to-extension/managed-preview.ts b/packages/nodes/src/lean-to-extension/managed-preview.ts new file mode 100644 index 0000000000..ff96610c32 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/managed-preview.ts @@ -0,0 +1,83 @@ +import type { AnyNode, AnyNodeId, LeanToExtensionNode, SceneApi } from '@pascal-app/core' +import { + isManagedLeanToNode, + isManagedLeanToPost, + leanToDownspoutLayoutPatch, + leanToGutterLayoutPatch, + leanToPostLayoutPatch, + leanToRoofSegmentLayoutPatch, + managedLeanToPostIndex, + managedLeanToPostSide, + resolveLeanToPostBaseY, + resolveLeanToPostGutterSetback, +} from './assembly' + +export function leanToManagedPreviewOverrides( + node: LeanToExtensionNode, + patch: Partial, + sceneApi: SceneApi, +): ReadonlyArray]> { + const next = { ...node, ...patch } as LeanToExtensionNode + const nodes = sceneApi.nodes() as Record + const entries: Array]> = [] + + const wall = next.parentId ? nodes[next.parentId as AnyNodeId] : undefined + for (const childId of next.children) { + const child = nodes[childId as AnyNodeId] + if (!child) continue + + if (child.type === 'column' && isManagedLeanToPost(child, next.id)) { + const index = managedLeanToPostIndex(child) + if (index === null) continue + const side = managedLeanToPostSide(child) + const baseY = + wall?.type === 'wall' ? resolveLeanToPostBaseY(next, wall, nodes, index, side) : 0 + const gutterSetback = side === 'low' ? resolveLeanToPostGutterSetback(next, child) : 0 + entries.push([ + child.id as AnyNodeId, + leanToPostLayoutPatch(next, index, baseY, gutterSetback, side) as Partial, + ]) + continue + } + + if (child.type !== 'roof' || !isManagedLeanToNode(child, next.id, 'roof')) continue + const segment = child.children + .map((id) => nodes[id as AnyNodeId]) + .find( + (candidate) => + candidate?.type === 'roof-segment' && + isManagedLeanToNode(candidate, next.id, 'roof-segment'), + ) + if (segment?.type !== 'roof-segment') continue + + const segmentPatch = leanToRoofSegmentLayoutPatch(next, nodes) + entries.push([segment.id as AnyNodeId, segmentPatch as Partial]) + + const nextSegment = { ...segment, ...segmentPatch } + const gutter = segment.children + .map((id) => nodes[id as AnyNodeId]) + .find( + (candidate) => + candidate?.type === 'gutter' && isManagedLeanToNode(candidate, next.id, 'gutter'), + ) + if (gutter?.type !== 'gutter') continue + const gutterPatch = leanToGutterLayoutPatch(nextSegment, next, gutter, nodes) + entries.push([gutter.id as AnyNodeId, gutterPatch as Partial]) + + const nextGutter = { ...gutter, ...gutterPatch } + const downspout = segment.children + .map((id) => nodes[id as AnyNodeId]) + .find( + (candidate) => + candidate?.type === 'downspout' && isManagedLeanToNode(candidate, next.id, 'downspout'), + ) + if (downspout?.type === 'downspout') { + entries.push([ + downspout.id as AnyNodeId, + leanToDownspoutLayoutPatch(nextSegment, nextGutter, next, downspout) as Partial, + ]) + } + } + + return entries +} diff --git a/packages/nodes/src/lean-to-extension/move-tool.tsx b/packages/nodes/src/lean-to-extension/move-tool.tsx index 7bc9495462..b32cce2eb8 100644 --- a/packages/nodes/src/lean-to-extension/move-tool.tsx +++ b/packages/nodes/src/lean-to-extension/move-tool.tsx @@ -6,13 +6,15 @@ import { emitter, 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 { resolveLeanToEdgeSnapTargets, resolveLeanToMoveProposal } from './layout' +import { leanToManagedPreviewOverrides } from './managed-preview' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' type MoveLeanToExtensionProps = { @@ -26,39 +28,86 @@ const MoveLeanToExtensionTool = ({ node, sceneApi }: MoveLeanToExtensionProps) = if (parent?.type !== 'wall') return const wall = parent as WallNode let lastPatch: Partial | null = null + let dragStartLocalY: number | null = null + const previewIds = new Set() + const movedObject = sceneRegistry.nodes.get(node.id) + 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 + dragStartLocalY ??= event.localPosition[1] const rawLocalX = event.localPosition[0] + 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, + rawHighEdgeHeight, + snapStep: gridStep, + edgeSnapTargets: event.nativeEvent.altKey + ? [] + : resolveLeanToEdgeSnapTargets(node, wall, nodes), + }) const position: LeanToExtensionNode['position'] = [ - resolveLeanToMoveCenterX( - node, - wall, - rawLocalX, - gridStep, - event.nativeEvent.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(node.id as AnyNodeId, patch) - sceneApi.markDirty(node.id as AnyNodeId) - lastPatch = leanToPlacementConflicts(candidate, wall, nodes).length === 0 ? patch : null + const previewEntries: ReadonlyArray]> = [ + [node.id as AnyNodeId, patch as Partial], + ...leanToManagedPreviewOverrides(node, patch, sceneApi), + ] + useLiveNodeOverrides.getState().setMany(previewEntries) + for (const [id] of previewEntries) { + previewIds.add(id) + sceneApi.markDirty(id) + } + lastPatch = + event.nativeEvent.altKey || leanToPlacementConflicts(candidate, wall, nodes).length === 0 + ? patch + : null return lastPatch } @@ -69,7 +118,7 @@ const MoveLeanToExtensionTool = ({ node, sceneApi }: MoveLeanToExtensionProps) = const patch = resolvePatch(event) if (!patch) return event.stopPropagation() - useLiveNodeOverrides.getState().clear(node.id as AnyNodeId) + for (const id of previewIds) useLiveNodeOverrides.getState().clear(id) sceneApi.update(node.id as AnyNodeId, patch as Partial) triggerSFX('sfx:structure-build') useEditor.getState().setMovingNode(null) @@ -82,8 +131,11 @@ const MoveLeanToExtensionTool = ({ node, sceneApi }: MoveLeanToExtensionProps) = 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) + for (const id of previewIds) { + useLiveNodeOverrides.getState().clear(id) + sceneApi.markDirty(id) + } + for (const restore of restoreRaycasts) restore() lastPatch = null } }, [node, sceneApi]) 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/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..bd02027ee5 100644 --- a/packages/nodes/src/lean-to-extension/roof-corner.test.ts +++ b/packages/nodes/src/lean-to-extension/roof-corner.test.ts @@ -14,7 +14,7 @@ import { bendLocalPoint } from './arc' import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' import { resolveLeanToCornerJoints } from './corner-joint' import { leanToWallLocalPose, resolveLeanToWallPlacement } from './layout' -import { applyLeanToWallAutoSpan } from './roof-attachment' +import { applyLeanToWallAutoSpan, applyLeanToWallCornerSpan } from './roof-attachment' function cornerFixture(reverseWalls = false, sideOverhang = 0) { const wallA = WallNode.parse({ @@ -687,6 +687,167 @@ describe('lean-to corner joint', () => { for (const mesh of expectedMeshes) mesh.geometry.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, 'front')!, + wallA, + ), + id: 'leanto_inward_chain_left', + } + const leanToB = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(wallB, getWallCurveLength(wallB) / 2, 'front')!, + wallB, + ), + id: 'leanto_inward_chain_center', + } + const leanToC = { + ...applyLeanToWallAutoSpan( + resolveLeanToWallPlacement(wallC, getWallCurveLength(wallC) / 2, 'front')!, + 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).toBe(leanToA.id) + expect(jointsB.right?.neighborId).toBe(leanToC.id) + expect(jointsB.left?.roofPiece.length).toBeGreaterThanOrEqual(3) + expect(jointsB.right?.roofPiece.length).toBeGreaterThanOrEqual(3) + + for (const [own, reciprocal, ownWall, ownLeanTo, reciprocalWall, reciprocalLeanTo] of [ + [jointsB.left, jointsA.right, wallB, leanToB, wallA, leanToA], + [jointsB.right, jointsC.left, 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).toHaveLength(1) + expect(centerAssembly.segment.shedFootprintPieces?.[0]).toHaveLength(5) + }) + test('resolves a reciprocal 60 degree corner with its true gutter mitre', () => { const { wallA, wallB, leanToA, leanToB, nodes } = angledCornerFixture(60) diff --git a/packages/nodes/src/lean-to-extension/system.test.ts b/packages/nodes/src/lean-to-extension/system.test.ts index b1af5b0404..cb06725320 100644 --- a/packages/nodes/src/lean-to-extension/system.test.ts +++ b/packages/nodes/src/lean-to-extension/system.test.ts @@ -6,12 +6,15 @@ import { createSceneApi, LeanToExtensionNode, LevelNode, + RoofNode, + RoofSegmentNode, type SceneCommit, subscribeSceneCommits, useScene, WallNode, } from '@pascal-app/core' import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' +import { resolveConicalLeanToPlacement } from './conical-host' import { initializeLeanToExtensionSync } from './system' type RafFn = (callback: (time: number) => void) => number @@ -107,6 +110,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 +332,86 @@ describe('lean-to scene commit boundary', () => { expect(cornerPosts).toHaveLength(1) }) + test('synchronizes an edge-snapped straight run with open gutters and one joint pillar', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_linear_sync', level: 0 }) + const wall = WallNode.parse({ + id: 'wall_linear_sync', + parentId: level.id, + start: [0, 0], + end: [12, 0], + }) + const left = LeanToExtensionNode.parse({ + id: 'leanto_linear_sync_left', + parentId: wall.id, + position: [2, 0, 0.05], + span: 4, + }) + const right = LeanToExtensionNode.parse({ + id: 'leanto_linear_sync_right', + parentId: wall.id, + position: [6.3, 0, 0.05], + span: 4, + }) + const leftAssembly = createLeanToAssembly(left) + const rightAssembly = createLeanToAssembly(right) + const nodes = Object.fromEntries( + [ + { ...level, children: [wall.id] }, + { ...wall, children: [left.id, right.id] }, + leftAssembly.extension, + ...leftAssembly.children, + rightAssembly.extension, + ...rightAssembly.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + const synced = useScene.getState().nodes + const extensions = [left.id, right.id].map((id) => synced[id as AnyNodeId]) + expect(extensions.every((node) => node?.type === 'lean-to-extension')).toBe(true) + const posts = extensions.flatMap((node) => + node?.type === 'lean-to-extension' + ? node.children + .map((id) => synced[id as AnyNodeId]) + .filter((child) => child?.type === 'column') + : [], + ) + const jointPosts = posts.filter((post) => { + if (post?.type !== 'column') return false + const index = managedLeanToPostIndex(post) + return index === leanToCornerPostIndex('left') || index === leanToCornerPostIndex('right') + }) + expect(jointPosts).toHaveLength(1) + + const gutters = extensions.map((node) => { + if (node?.type !== 'lean-to-extension') return undefined + const roof = node.children + .map((id) => synced[id as AnyNodeId]) + .find((child) => child?.type === 'roof') + if (roof?.type !== 'roof') return undefined + const segment = roof.children + .map((id) => synced[id as AnyNodeId]) + .find((child) => child?.type === 'roof-segment') + return segment?.type === 'roof-segment' + ? segment.children + .map((id) => synced[id as AnyNodeId]) + .find((child) => child?.type === 'gutter') + : undefined + }) + expect(gutters[0]?.type === 'gutter' && gutters[0].endCapRight).toBe(false) + expect(gutters[1]?.type === 'gutter' && gutters[1].endCapLeft).toBe(false) + }) + test('removes regular posts outside a synchronized internal L valley', () => { stopSync() const level = LevelNode.parse({ id: 'level_inner_post_sync', level: 0 }) diff --git a/packages/nodes/src/lean-to-extension/system.tsx b/packages/nodes/src/lean-to-extension/system.tsx index 285727bd73..dcb1f2da17 100644 --- a/packages/nodes/src/lean-to-extension/system.tsx +++ b/packages/nodes/src/lean-to-extension/system.tsx @@ -35,6 +35,7 @@ import { resolveLeanToPostGutterSetback, resolveLeanToPostIndexes, } from './assembly' +import { resolveConicalLeanToPlacement } from './conical-host' import { LEAN_TO_CORNER_JOINTS_KEY, leanToCornerJointMetadata, @@ -46,6 +47,7 @@ import { applyLeanToAvailableWallSpan, applyLeanToRoofAttachment, applyLeanToWallAutoSpan, + applyLeanToWallCornerSpan, clearLeanToRoofAttachment, resolveLeanToHostRoof, resolveLeanToRoofAttachment, @@ -173,6 +175,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) ) @@ -236,6 +240,7 @@ function extensionSignature( ): string { return JSON.stringify([ leanToGroundSignature(leanTo, nodes), + leanTo.hostKind, leanTo.span, leanTo.spanArcCenterZ, leanTo.spanArcRadius, @@ -348,11 +353,14 @@ function resolveEffectiveLeanTo( nodes: Record, ): LeanToExtensionNode { const parent = leanTo.parentId ? nodes[leanTo.parentId as AnyNodeId] : undefined + if (parent?.type === 'roof-segment' && leanTo.hostKind === 'conical-roof') { + return resolveConicalLeanToPlacement(parent, leanTo) ?? leanTo + } if (parent?.type !== 'wall') { return leanTo.connectionMode === 'manual' ? leanTo : clearLeanToRoofAttachment(leanTo) } 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 +377,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, diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx index 3f31e3860e..5942060f88 100644 --- a/packages/nodes/src/lean-to-extension/tool.tsx +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -3,9 +3,13 @@ import { type AnyNode, type AnyNodeId, + type DoorEvent, emitter, getLevelElevations, getWallBaseElevationForNodes, + type RoofEvent, + type RoofSegmentEvent, + sceneRegistry, type WallEvent, type WallNode, } from '@pascal-app/core' @@ -16,7 +20,9 @@ import { useRegistryToolContext, } from '@pascal-app/editor' import { useEffect, useState } from 'react' +import { Euler, Quaternion, Vector3 } from 'three' import { createLeanToAssembly } from './assembly' +import { isConicalLeanToHostOccupied, resolveConicalLeanToSurfaceHit } from './conical-host' import { leanToExtensionGeometryKey } from './geometry' import { leanToWallLocalPose, @@ -34,6 +40,7 @@ import { resolveLeanToRoofAttachment, } from './roof-attachment' import type { LeanToExtensionNode } from './schema' +import { resolveLeanToDoorWallTarget } from './wall-target' type PreviewPose = { node: LeanToExtensionNode @@ -56,6 +63,98 @@ const LeanToExtensionTool = () => { return levelY + getWallBaseElevationForNodes(wall, nodes) } + const commitNode = (node: LeanToExtensionNode, parentId: AnyNodeId) => { + 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, + })), + ]) + selectNode(assembly.extension.id as AnyNodeId) + triggerSFX('sfx:structure-build') + if (useEditor.getState().getContinuation('point') !== 'repeat') { + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + } + } + + const worldPreviewPose = ( + event: RoofEvent | RoofSegmentEvent, + node: LeanToExtensionNode, + localPosition: readonly [number, number, number], + extraRotationY = 0, + ): 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, + } + } + + const updateConicalSegmentTarget = (event: RoofSegmentEvent) => { + const nodes = sceneApi.nodes() as Record + if (isConicalLeanToHostOccupied(event.node.id, nodes)) { + setPreview(null) + return null + } + const node = resolveConicalLeanToSurfaceHit(event.node, event.localPosition, event.normal) + if (!node) { + setPreview(null) + return null + } + setPreview(worldPreviewPose(event, node, node.position)) + return node + } + + const updateConicalRoofTarget = (event: RoofEvent) => { + if (event.object.name !== 'merged-roof') return null + const nodes = sceneApi.nodes() as Record + for (const childId of event.node.children) { + const segment = nodes[childId as AnyNodeId] + if (segment?.type !== 'roof-segment' || segment.roofType !== 'conical') continue + if (isConicalLeanToHostOccupied(segment.id, nodes)) 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 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, + ), + ) + return node + } + setPreview(null) + return null + } + const updateTarget = (event: WallEvent) => { const hit = resolveLeanToWallSurfaceHit(event.node, event.localPosition, event.normal) if (!hit) { @@ -104,32 +203,91 @@ const LeanToExtensionTool = () => { 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') + commitNode(node, event.node.id as AnyNodeId) + } + + const onDoorMove = (event: DoorEvent) => { + 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)) { + setPreview(null) + return } + updateTarget(resolveLeanToDoorWallTarget(event, wall, wallObject)) + } + + const onDoorClick = (event: DoorEvent) => { + 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) + const node = updateTarget(target) + if (!node) return + event.stopPropagation() + commitNode(node, wall.id as AnyNodeId) + } + + const onDoorLeave = () => { + setPreview(null) + } + + const onRoofSegmentMove = (event: RoofSegmentEvent) => { + updateConicalSegmentTarget(event) + } + const onRoofSegmentClick = (event: RoofSegmentEvent) => { + const node = updateConicalSegmentTarget(event) + if (!node) return + event.stopPropagation() + commitNode(node, event.node.id as AnyNodeId) + } + const onRoofMove = (event: RoofEvent) => { + updateConicalRoofTarget(event) + } + const onRoofClick = (event: RoofEvent) => { + const node = updateConicalRoofTarget(event) + if (!node) return + const segment = node.parentId ? sceneApi.get(node.parentId as AnyNodeId) : undefined + if (segment?.type !== 'roof-segment') return + event.stopPropagation() + commitNode(node, segment.id as AnyNodeId) } 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) 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) setPreview(null) useInteractionScope .getState() 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..55de482c3c 100644 --- a/packages/nodes/src/roof-segment/definition.test.ts +++ b/packages/nodes/src/roof-segment/definition.test.ts @@ -64,6 +64,23 @@ function pitchHandle(): LinearResizeHandle { } describe('roof-segment resize handles', () => { + test('keeps conical diameter circular and omits rotation', () => { + const node = segment({ roofType: 'conical', width: 6, depth: 6 }) + const conicalHandles = handles(node) + const widthHandle = conicalHandles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === 'min', + ) + const depthHandle = conicalHandles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'z' && handle.anchor === 'min', + ) + + expect(widthHandle?.apply(node, 8, undefined as never)).toMatchObject({ width: 8, depth: 8 }) + expect(depthHandle?.apply(node, 9, undefined as never)).toMatchObject({ width: 9, depth: 9 }) + 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) diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 1ded893143..930b1ec07a 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -87,6 +87,7 @@ function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor[] = [ function resolveRoofSegmentHandles( node: RoofSegmentNodeType, ): HandleDescriptor[] { - return isManagedLeanToRoofSegment(node) ? [] : roofSegmentHandles + if (isManagedLeanToRoofSegment(node)) return [] + return node.roofType === 'conical' ? roofSegmentHandles.slice(0, -1) : roofSegmentHandles } /** diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.ts b/packages/nodes/src/roof-segment/floorplan-affordances.ts index 70fa8ecd0d..f7ddae536e 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -101,12 +101,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 +121,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 }) }, } }, diff --git a/packages/nodes/src/roof-segment/floorplan.test.ts b/packages/nodes/src/roof-segment/floorplan.test.ts index 6b0ab302ea..33e73f45d5 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,31 @@ 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) + expect(getRoofSegmentPlanLinework(node)).toEqual({ + ridges: [], + hips: [], + breaks: [], + slope: null, + }) + }) + test('draws a dutch width-axis upper ridge plus waist linework', () => { const linework = getRoofSegmentPlanLinework(dutchSegment()) diff --git a/packages/nodes/src/roof-segment/floorplan.ts b/packages/nodes/src/roof-segment/floorplan.ts index 43a36b634e..ccb0e714b4 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -73,19 +73,33 @@ export function buildRoofSegmentFloorplan( const baseInk = '#111111' const stroke = showSelectedChrome && palette ? palette.selectedStroke : baseInk + const footprint: FloorplanGeometry = + node.roofType === 'conical' + ? { + kind: 'circle', + cx, + cy: cz, + r: halfWidth, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + } + : { + kind: 'polygon', + points, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + } const children: FloorplanGeometry[] = [ // Invisible hit-target — full footprint, transparent fill, captures // clicks across the entire roof rectangle (so the user doesn't need // to pixel-hunt the outline strokes). - { - kind: 'polygon', - points, - fill: stroke, - fillOpacity: 0, - stroke: 'none', - strokeWidth: 0, - pointerEvents: 'all', - }, + footprint, ] // The segment's own rectangle outline + fill render ONLY while it's @@ -95,15 +109,28 @@ export function buildRoofSegmentFloorplan( // (`buildRoofFloorplan`), so overlapping segments read as one combined // shape instead of stacked rectangles. Ridges/hips below always draw. if (showSelectedChrome) { - children.push({ - kind: 'polygon', - points, - fill: '#fed7aa', - fillOpacity: 0.55, - stroke, - strokeWidth: 0.035, - strokeLinejoin: 'miter', - }) + children.push( + node.roofType === 'conical' + ? { + kind: 'circle', + cx, + cy: cz, + r: halfWidth, + fill: '#fed7aa', + fillOpacity: 0.55, + stroke, + strokeWidth: 0.035, + } + : { + kind: 'polygon', + points, + fill: '#fed7aa', + fillOpacity: 0.55, + stroke, + strokeWidth: 0.035, + strokeLinejoin: 'miter', + }, + ) } // NOTE: the ridge / hip / break / slope linework is NOT drawn here — the @@ -161,15 +188,17 @@ export function buildRoofSegmentFloorplan( // Rotate-arrow at the +X / +Z corner. Local angle π/4 puts the // curved arrow's bow at the diagonal corner so it reads as a // rotation gizmo around the segment centre. - const [cornerX, cornerZ] = rotateLocal(halfW + rotateCornerOffset, halfD + rotateCornerOffset) - const [radialX, radialZ] = rotateLocal(1, 1) - children.push({ - kind: 'rotate-arrow', - point: [cx + cornerX, cz + cornerZ], - angle: Math.atan2(radialZ, radialX), - affordance: 'roof-segment-rotate', - pivot: [cx, cz], - }) + if (node.roofType !== 'conical') { + const [cornerX, cornerZ] = rotateLocal(halfW + rotateCornerOffset, halfD + rotateCornerOffset) + const [radialX, radialZ] = rotateLocal(1, 1) + children.push({ + kind: 'rotate-arrow', + point: [cx + cornerX, cz + cornerZ], + angle: Math.atan2(radialZ, radialX), + affordance: 'roof-segment-rotate', + pivot: [cx, cz], + }) + } } return { kind: 'group', children } @@ -233,6 +262,7 @@ export function getRoofSegmentPlanLinework(node: RoofSegmentNode): { } switch (node.roofType) { + case 'conical': case 'flat': break case 'gable': diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index 0f5ee3c4e9..edba6a81b8 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -43,6 +43,10 @@ const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [ { label: 'Mansard', value: 'mansard' }, ] +const ROOF_TYPE_OPTIONS_3: { label: string; value: RoofType }[] = [ + { label: 'Conical', value: 'conical' }, +] + // Carpenter / roofer convention: rise over a 12" run, converted to degrees. // atan(3/12) ≈ 14.04°, atan(6/12) ≈ 26.57°, atan(9/12) ≈ 36.87°, atan(12/12) = 45°. const PITCH_PRESETS: { label: string; deg: number }[] = [ @@ -127,23 +131,43 @@ export default function RoofSegmentPanel() { const handleRoofTypeChange = useCallback( (roofType: RoofType) => { if (isManagedLeanToRoofSegment(node?.metadata)) return + if (roofType === 'conical' && node) { + const scene = useScene.getState() + const defaultVentIds = (node.children ?? []).filter((childId) => + isDefaultRidgeVentNode(scene.nodes[childId as AnyNodeId], node.id), + ) as AnyNodeId[] + if (defaultVentIds.length > 0) scene.deleteNodes(defaultVentIds) + } // Switching to Dutch resets the shape parameters to their defaults so the // gablet is well-formed regardless of the leftover values from the // previous roof type. handleUpdate( - roofType === 'dutch' + roofType === 'conical' ? { roofType, - dutchHipWidthRatio: ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, - dutchHipHeightRatio: ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, - dutchWaistLengthRatio: ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, - dutchGabletRake: ROOF_SHAPE_DEFAULTS.dutchGabletRake, - dutchTopRakeThickness: ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness, + depth: node?.width ?? 8, + rotation: 0, + trim: EMPTY_TRIM, + metadata: { + ...metadataRecord(node?.metadata), + autoGutter: false, + autoRidgeVent: false, + showTrimPlanes: false, + }, } - : { roofType }, + : roofType === 'dutch' + ? { + roofType, + dutchHipWidthRatio: ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + dutchHipHeightRatio: ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + dutchWaistLengthRatio: ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + dutchGabletRake: ROOF_SHAPE_DEFAULTS.dutchGabletRake, + dutchTopRakeThickness: ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness, + } + : { roofType }, ) }, - [handleUpdate, node?.metadata], + [handleUpdate, node], ) const handleClose = useCallback(() => { @@ -310,67 +334,92 @@ export default function RoofSegmentPanel() { value={node.roofType} disabled={managedLeanToRoofSegment} /> + handleRoofTypeChange(v)} + options={ROOF_TYPE_OPTIONS_3} + value={node.roofType} + disabled={managedLeanToRoofSegment} + /> - - - - ) : ( - - ) - } - label={showTrimPlanes ? 'Done editing' : 'Edit footprint'} - onClick={() => (showTrimPlanes ? handleBack() : handleTrimEditing(true))} - /> - } - label="Reset" - onClick={handleResetTrim} - /> - - {node.roofType !== 'shed' && node.roofType !== 'flat' && ( + {node.roofType !== 'conical' && ( + + + + ) : ( + + ) + } + label={showTrimPlanes ? 'Done editing' : 'Edit footprint'} + onClick={() => (showTrimPlanes ? handleBack() : handleTrimEditing(true))} + /> + } + label="Reset" + onClick={handleResetTrim} + /> + + {node.roofType !== 'shed' && node.roofType !== 'flat' && ( + + )} + + )} + + {node.roofType !== 'conical' && ( + - )} - - - - - + + )} - handleUpdate({ width: v })} - precision={2} - step={0.5} - unit="m" - value={Math.round(node.width * 100) / 100} - /> - handleUpdate({ depth: v })} - precision={2} - step={0.5} - unit="m" - value={Math.round(node.depth * 100) / 100} - /> + {node.roofType === 'conical' ? ( + handleUpdate({ width: v, depth: v })} + precision={2} + step={0.5} + unit="m" + value={Math.round(node.width * 100) / 100} + /> + ) : ( + <> + handleUpdate({ width: v })} + precision={2} + step={0.5} + unit="m" + value={Math.round(node.width * 100) / 100} + /> + handleUpdate({ depth: v })} + precision={2} + step={0.5} + unit="m" + value={Math.round(node.depth * 100) / 100} + /> + + )} @@ -609,34 +658,38 @@ export default function RoofSegmentPanel() { unit="m" value={Math.round(node.position[2] * 100) / 100} /> - { - handleUpdate({ rotation: (degrees * Math.PI) / 180 }) - }} - precision={0} - step={1} - unit="°" - value={Math.round((node.rotation * 180) / Math.PI)} - /> -
- { - triggerSFX('sfx:item-rotate') - handleUpdate({ rotation: node.rotation - Math.PI / 4 }) - }} - /> - { - triggerSFX('sfx:item-rotate') - handleUpdate({ rotation: node.rotation + Math.PI / 4 }) - }} - /> -
+ {node.roofType !== 'conical' && ( + <> + { + handleUpdate({ rotation: (degrees * Math.PI) / 180 }) + }} + precision={0} + step={1} + unit="°" + value={Math.round((node.rotation * 180) / Math.PI)} + /> +
+ { + triggerSFX('sfx:item-rotate') + handleUpdate({ rotation: node.rotation - Math.PI / 4 }) + }} + /> + { + triggerSFX('sfx:item-rotate') + handleUpdate({ rotation: node.rotation + Math.PI / 4 }) + }} + /> +
+ + )}
diff --git a/packages/nodes/src/roof/floorplan.test.ts b/packages/nodes/src/roof/floorplan.test.ts index dc53153e1a..3cebb07735 100644 --- a/packages/nodes/src/roof/floorplan.test.ts +++ b/packages/nodes/src/roof/floorplan.test.ts @@ -82,4 +82,55 @@ describe('buildRoofFloorplan roof intersections', () => { expect(Math.min(...hostOutline.map(([x]) => x))).toBeCloseTo(-5, 6) expect(Math.max(...hostOutline.map(([x]) => x))).toBeCloseTo(5, 6) }) + + test('keeps a mounted conical roof visible above its host in plan view', () => { + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + children: ['rseg_host'], + }) + const conicalRoof = RoofNode.parse({ + id: 'roof_conical', + type: 'roof', + children: ['rseg_conical'], + support: { + kind: 'roof', + roofSegmentId: 'rseg_host', + localPosition: [0, 0], + curbHeight: 0.5, + }, + }) + const hostSegment = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'gable', + width: 10, + depth: 8, + }) + const conicalSegment = RoofSegmentNode.parse({ + id: 'rseg_conical', + type: 'roof-segment', + parentId: conicalRoof.id, + roofType: 'conical', + width: 3, + depth: 3, + }) + const nodes = { + [hostRoof.id]: hostRoof, + [conicalRoof.id]: conicalRoof, + [hostSegment.id]: hostSegment, + [conicalSegment.id]: conicalSegment, + } + + const geometry = buildRoofFloorplan( + conicalRoof, + buildContext(conicalRoof, [conicalSegment], [hostRoof], nodes), + ) + const outline = outlinePoints(geometry) + + expect(geometry).not.toBeNull() + expect(Math.min(...outline.map(([x]) => x))).toBeCloseTo(-1.5, 6) + expect(Math.max(...outline.map(([x]) => x))).toBeCloseTo(1.5, 6) + }) }) diff --git a/packages/nodes/src/roof/floorplan.ts b/packages/nodes/src/roof/floorplan.ts index 02a6eec796..4a9fd29296 100644 --- a/packages/nodes/src/roof/floorplan.ts +++ b/packages/nodes/src/roof/floorplan.ts @@ -4,7 +4,7 @@ import { type GeometryContext, type RoofNode, type RoofSegmentNode, - roofOverlapEntryOwns, + roofPlanOverlapEntryOwns, subtractPolygonsFromPolygon, unionPolygons, } from '@pascal-app/core' @@ -27,6 +27,26 @@ type PlanEntry = { plan: SegPlan } +function overlapEntry(entry: PlanEntry, ctx: GeometryContext) { + const supportSegment = + entry.roof.support?.kind === 'roof' + ? ctx.resolve(entry.roof.support.roofSegmentId) + : undefined + return { + roofId: String(entry.roof.id), + segmentId: String(entry.segment.id), + supportRoofId: + supportSegment?.type === 'roof-segment' && supportSegment.parentId + ? String(supportSegment.parentId) + : undefined, + supportRoofSegmentId: + entry.roof.support?.kind === 'roof' ? String(entry.roof.support.roofSegmentId) : undefined, + roofType: entry.segment.roofType, + width: entry.segment.width, + depth: entry.segment.depth, + } +} + /** A segment's footprint + ridge/hip/break/slope linework, in world plan coords. */ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { const cosRoof = Math.cos(-roof.rotation) @@ -47,8 +67,15 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { tp(s[0][0], s[0][1]), tp(s[1][0], s[1][1]), ] + const footprint = + seg.roofType === 'conical' + ? Array.from({ length: 48 }, (_, index) => { + const angle = (-index / 48) * Math.PI * 2 + return tp(Math.cos(angle) * hw, Math.sin(angle) * hw) + }) + : [tp(-hw, -hd), tp(hw, -hd), tp(hw, hd), tp(-hw, hd)] return { - footprint: [tp(-hw, -hd), tp(hw, -hd), tp(hw, hd), tp(-hw, hd)], + footprint, ridges: lw.ridges.map(mapSeg), hips: lw.hips.map(mapSeg), breaks: lw.breaks.map(mapSeg), @@ -153,20 +180,7 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp .filter((candidate) => { if (candidate.segment.id === entry.segment.id) return false if (candidate.segment.roofType === 'shed') return false - return roofOverlapEntryOwns( - { - roofId: String(candidate.roof.id), - segmentId: String(candidate.segment.id), - width: candidate.segment.width, - depth: candidate.segment.depth, - }, - { - roofId: String(entry.roof.id), - segmentId: String(entry.segment.id), - width: entry.segment.width, - depth: entry.segment.depth, - }, - ) + return roofPlanOverlapEntryOwns(overlapEntry(candidate, ctx), overlapEntry(entry, ctx)) }) .map((candidate) => candidate.plan.footprint) return { diff --git a/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts b/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts new file mode 100644 index 0000000000..b8d58e8206 --- /dev/null +++ b/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, type DormerEvent, DormerNode, WindowNode } from '@pascal-app/core' +import { + 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('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) + }) +}) + +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..5b544987b6 --- /dev/null +++ b/packages/nodes/src/shared/dormer-wall-opening-placement.ts @@ -0,0 +1,127 @@ +import { + type AnyNode, + type DormerEvent, + type DormerNode, + getDormerWallFaceFrame, + getDormerWallVerticalBounds, + type WindowNode, +} from '@pascal-app/core' + +export type DormerWindowTarget = { + dormer: DormerNode + face: NonNullable + position: [number, number, number] + valid: boolean +} + +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 toFaceLocalPoint( + dormer: DormerNode, + face: DormerWindowTarget['face'], + point: [number, number, number], +): [number, number, number] { + const frame = getDormerWallFaceFrame(dormer, face) + const dx = point[0] - frame.origin[0] + const dz = point[2] - frame.origin[2] + return [ + Math.cos(frame.yaw) * dx + Math.sin(frame.yaw) * dz, + point[1], + -Math.sin(frame.yaw) * dx + Math.cos(frame.yaw) * dz, + ] +} + +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 +}): DormerWindowTarget | null { + const { event, width, height, nodes, ignoreId } = args + const face = faceFromNormal(event.normal) ?? faceFromPoint(event.node, event.localPosition) + + const point = toFaceLocalPoint(event.node, face, event.localPosition) + const frame = getDormerWallFaceFrame(event.node, face) + const vertical = getDormerWallVerticalBounds(event.node) + const clampedX = Math.max( + width / 2, + Math.min(frame.width - width / 2, point[0] + frame.width / 2), + ) + 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, point[1])) + const position: [number, number, number] = [clampedX - frame.width / 2, clampedY, 0] + + return { + dormer: event.node, + face, + position, + valid: !hasWindowOverlap(event.node, nodes, face, position, width, height, ignoreId), + } +} diff --git a/packages/nodes/src/shared/ridge-snap.ts b/packages/nodes/src/shared/ridge-snap.ts index bb2f569709..d5c5c8cdf7 100644 --- a/packages/nodes/src/shared/ridge-snap.ts +++ b/packages/nodes/src/shared/ridge-snap.ts @@ -43,7 +43,7 @@ export function resolveRidgeSnap( cursorLocalZ: number, ): RidgeSnap | null { const roofType = segment.roofType ?? 'gable' - if (roofType === 'flat') return null + if (roofType === 'flat' || roofType === 'conical') return null const lines = roofType === 'shed' diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts index aa6369563c..78343f696a 100644 --- a/packages/nodes/src/shared/roof-surface.ts +++ b/packages/nodes/src/shared/roof-surface.ts @@ -136,7 +136,12 @@ function buildRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { let shinTopD = shinBotD let transZ = 0 - if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') { + if ( + roofType === 'hip' || + roofType === 'mansard' || + roofType === 'dutch' || + roofType === 'conical' + ) { shinTopW += 2 * stSin shinTopD += 2 * stSin } else if (roofType === 'gable' || roofType === 'gambrel') { @@ -434,6 +439,12 @@ export function getAnalyticalNormal( return buildSlopeNormal(0, 1, primaryTan, out) } + if (roofType === 'conical') { + const radius = Math.hypot(lx, lz) + if (radius <= 1e-6) return out.set(0, 1, 0) + return buildSlopeNormal(lx / radius, lz / radius, primaryTan, out) + } + // 4-sided slopes: the dominant axis chooses which face the point sits // on. Hip is uniform across all four faces. Mansard has a steep outer // band (primaryTan) and a shallow top inside the waist. Dutch has hip diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index ef050209fa..c95c7bd284 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -199,9 +199,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/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 432510c2d5..34f22ab2b0 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,7 +1,9 @@ import { type AnyNodeId, + type DormerEvent, emitter, type GridEvent, + getDormerWallFaceFrame, holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, @@ -34,6 +36,11 @@ 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, + resolveDormerWindowTarget, + shouldWriteDormerWindowPreviewHost, +} from '../shared/dormer-wall-opening-placement' import { clearOpeningGuides3D, publishOpeningGuidesForWallEvent, @@ -151,6 +158,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 +253,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) @@ -609,6 +620,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, @@ -697,6 +710,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 @@ -755,6 +770,172 @@ 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, + }) + + const dormerWindowWorldPosition = (event: DormerEvent, target: DormerWindowTarget) => { + const frame = getDormerWallFaceFrame(event.node, target.face) + const point = new Vector3( + frame.origin[0] + target.position[0] * Math.cos(frame.yaw), + target.position[1], + frame.origin[2] + target.position[0] * Math.sin(frame.yaw), + ) + 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 + } + const liveNode = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as WindowNode + if (shouldWriteDormerWindowPreviewHost(liveNode, target)) { + useScene.getState().updateNode(movingWindowNode.id, { + position: target.position, + rotation, + side, + parentId: target.dormer.id, + dormerId: target.dormer.id, + dormerFace: target.face, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + visible: false, + }) + markHostDirtyThrottled(target.dormer.id) + } + setGhostPose({ + position: dormerWindowWorldPosition(event, target), + rotationY: target.face === 'front' ? 0 : Math.PI, + tint: target.valid || altHeld ? 'valid' : 'invalid', + floorY: dormerWindowWorldPosition(event, target)[1], + side, + }) + useFacingPose.getState().clear() + clearOpeningGuides3D() + } + + const commitToDormer = (event: DormerEvent, target: DormerWindowTarget) => { + if (committed) return + committed = true + 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() + useViewer.getState().setSelection({ selectedIds: [placedId] }) + 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() + useLiveTransforms.getState().clear(movingWindowNode.id) + lastDormerEvent = null + lastDormerTarget = null + } + // ── 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 @@ -869,6 +1050,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, @@ -927,6 +1110,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 +1138,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 +1168,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 +1203,10 @@ 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('grid:move', onGridMove) emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) @@ -1078,6 +1271,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, @@ -1106,6 +1301,10 @@ 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('grid:move', onGridMove) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) 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/tool.tsx b/packages/nodes/src/window/tool.tsx index 90482adda1..9d6cf9395d 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -1,8 +1,11 @@ import { type AnyNode, type AnyNodeId, + type DormerEvent, + type DormerNode, emitter, type GridEvent, + getDormerWallFaceFrame, holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, @@ -13,6 +16,7 @@ import { type WallEvent, type WallNode, WallNode as WallNodeSchema, + type WindowEvent, WindowNode, } from '@pascal-app/core' import { @@ -33,6 +37,10 @@ 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, + resolveDormerWindowTarget, +} from '../shared/dormer-wall-opening-placement' import { clearOpeningGuides3D, publishOpeningGuidesForWallEvent, @@ -77,7 +85,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 @@ -163,6 +171,7 @@ 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 @@ -291,6 +300,59 @@ const WindowTool: React.FC = () => { ) } + const dormerWindowWorldPosition = (event: DormerEvent, target: DormerWindowTarget) => { + const frame = getDormerWallFaceFrame(event.node, target.face) + const [u, v] = target.position + const point = roofFallbackPoint.set( + frame.origin[0] + u * Math.cos(frame.yaw), + v, + frame.origin[2] + u * Math.sin(frame.yaw), + ) + 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 { + useScene.getState().updateNode(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() + updateCursor( + dormerWindowWorldPosition(event, target), + target.face === 'front' ? 0 : Math.PI, + 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 @@ -514,6 +576,65 @@ const WindowTool: React.FC = () => { } } + const commitWindowAtDormer = (dormer: DormerNode, target: DormerWindowTarget) => { + const draft = draftRef.current + if (!draft) return + clearPlacementPreview() + draftRef.current = null + hostKind = null + + 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) + useViewer.getState().setSelection({ selectedIds: [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' @@ -606,6 +727,109 @@ 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, + }) + + 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 + + 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, + } + } + + 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), @@ -759,6 +983,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 +999,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,6 +1032,14 @@ 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) 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..27e113f9fc 100644 --- a/packages/viewer/src/systems/roof/roof-system.test.ts +++ b/packages/viewer/src/systems/roof/roof-system.test.ts @@ -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,49 @@ 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('keeps configured shed side infill on the outer side-member face', () => { const span = 4 const leftOverhang = 0.15 @@ -102,6 +139,7 @@ describe('roof system shed geometry', () => { shedSideInfillSpan: span, shedSideInfillMinX: -infillHalfWidth, shedSideInfillMaxX: infillHalfWidth, + metadata: { managedByLeanTo: 'lean_to_test', leanToRole: 'roof-segment' }, }) const { geometry, roofSideX, sideInfillNormals, sideInfillX } = inspectShedGeometry(segment) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 88577f7b95..41ea5c4ad0 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -636,7 +636,7 @@ function updateMergedRoofGeometry( totalDeckSlab = brushes.deckSlab } - if (child.roofType === 'shed') { + if (child.roofType === 'shed' && isManagedLeanToRoofSegment(child)) { brushes.wallBrush.geometry.dispose() brushes.innerBrush.geometry.dispose() } else { @@ -1036,6 +1036,13 @@ function readShedOpenEndSides(node: RoofSegmentNode): Set { return new Set(value.filter((side): side is ShedEndSide => side === 'left' || side === 'right')) } +function isManagedLeanToRoofSegment(node: Pick): boolean { + const metadata = node.metadata + if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return false + const record = metadata as Record + return record.managedByLeanTo !== undefined && record.leanToRole === 'roof-segment' +} + function hasSegmentTrim(node: RoofSegmentNode): boolean { const trim = normalizeRoofSegmentTrim(node) return ( @@ -1430,7 +1437,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)) { @@ -1684,7 +1691,7 @@ export function generateRoofSegmentGeometry( prepareBrushForCSG(shinDeck) let combined = shinDeck let hollowWall: Brush | null = null - if (node.roofType !== 'shed') { + if (!(node.roofType === 'shed' && isManagedLeanToRoofSegment(node))) { hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION) prepareBrushForCSG(hollowWall) combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) @@ -1813,18 +1820,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 +1860,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, @@ -2863,7 +2882,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' && isManagedLeanToRoofSegment(segment), + ) if (shedSegments.length === 0) return geometry const panelGeometries: THREE.BufferGeometry[] = [] @@ -3115,7 +3136,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 +3167,7 @@ export function getRoofOuterSurfaceFrameAtPoint( let iL = 0 let iR = 0 - if (roofType === 'hip') { + if (roofType === 'hip' || roofType === 'conical') { iF = inset iB = inset iL = inset diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 96bc92827b..4b8e368391 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -96,6 +96,11 @@ export function MyTool() { mode-positioned point, so grid quantise / angle lock / free placement are respected right up to the wall and only the last few cm stick. It is **not** a Shift bypass and must not be gated on modifiers. See `snapWallDraftPointDetailed` in `components/tools/wall/wall-drafting.ts`. + - **Sanctioned exception — lean-to structural connection snap.** Moving or resizing a + `lean-to-extension` keeps a tight, mode-independent edge/height catch to a neighboring + extension. This is connectivity: the joined roofs become one structural run with shared + gutter ends and a single joint post. It runs after the active grid/free proposal and is + bypassed only by held Alt. The same rule applies in 2D and 3D. - **Constraints and guides can be decoupled.** When a stronger constraint owns the proposal — a wall segment's 45° lock while in `angles` mode — the tool may still publish passive dashed alignment/proximity guides as long as it does not apply the guide snap delta. Use this for chained diff --git a/wiki/conical-roof-implementation-plan.md b/wiki/conical-roof-implementation-plan.md new file mode 100644 index 0000000000..684f44c89a --- /dev/null +++ b/wiki/conical-roof-implementation-plan.md @@ -0,0 +1,52 @@ +# Conical roof implementation plan + +## Decision + +Add `conical` to `RoofSegmentNode` and expose a **Conical roof** preset under Roof features. Each placement creates an independent `RoofNode` assembly containing the conical segment, so the assembly can be selected and moved separately while reusing roof materials, hosted accessories, merged-roof CSG, and sibling trimming. + +The first release is a regular cone over a circular plan. Bell, ogee, onion, and dome profiles are separate future shapes rather than settings on the cone. The terminology and option survey are recorded in [conical-turret-roof-research.md](conical-turret-roof-research.md). + +## Data contract + +- `roofType` is `conical`. +- `width` is the canonical diameter; all editor creation and resize paths keep `depth === width`. +- Pitch uses the existing roof convention. Rise is `diameter / 2 * tan(pitch)`. +- Rotation is stored for schema consistency but has no visible effect and receives no editing handle. +- `RoofNode.support` records either level placement or a roof-surface attachment. A roof attachment stores the host segment, host-local center, and curb height. +- Rectangular manual trim controls are unavailable. Intersections use the existing solid-to-solid roof trimming pipeline. +- Overhang, wall height, wall/deck/covering thickness, and surface materials remain the existing roof-segment fields. + +## Geometry and trimming + +1. Generate a closed circular wall-and-cone volume with renderer-owned radial tessellation. +2. Generate deck and covering layers through the existing inset/offset brush path. +3. Feed the solid into the existing merged-roof CSG pipeline. The declared host clips the mounted assembly at its outer surface, matching the chimney rule: the cylindrical body is removed inside the host while the host shell stays intact and the cone remains above it. Plan view keeps the mounted circle visible above the host. A level-supported cone uses the normal area-based overlap rule. +4. Recompute intersections whenever either segment moves or changes dimensions, using the existing roof dependency invalidation. +5. Keep straight-edge gutters and ridge vents unavailable for this profile. Circular eave trim and a finial are follow-up accessories. + +## Interaction + +- Build panel: selecting **Conical roof** activates the standard roof tool with a conical default. +- Placement surface: `Auto` chooses a complete roof support under the circle and otherwise uses the level. `Ground` always uses the level. `Roof` requires a complete roof support and blocks invalid commits. The contextual HUD chip and `P` cycle these modes. +- 3D placement: the two-point footprint gesture resolves to a circle whose diameter is the larger drag span. +- 2D placement: use the same resolver and committed invariant as 3D. +- Resize: any side handle changes the diameter on both axes; rotation handles are hidden. +- Inspector: show one Diameter control, pitch, wall height, overhang, structure, and materials. Hide rectangular trim, drainage, and ridge-vent automation. +- Floorplan: render and hit-test a circle; approximate it only at the polygon-union boundary used to compute the merged roof silhouette. + +## Verification + +- Schema parses and serializes `conical`. +- A diameter of 8 m at 45° produces a 4 m roof rise. +- The generated shell is closed and uses one circular eave and one apex. +- Surface height falls linearly with radial distance. +- Placement and every resize path preserve `width === depth`. +- 2D selection and hit targets are circular. +- A mounted conical wall clips at the declared host surface without cutting the host shell or disappearing in plan view. +- A mounted roof attachment survives scene and level cloning with its host segment reference remapped. +- Removing or moving it restores/recomputes the sibling roof. +- Focused unit tests, package type checks, and interactive 2D/3D placement checks must pass before release. + +## Follow-up profiles + +Introduce an explicit radial-profile discriminator only when the corresponding geometry ships: `bell`, `ogee`, `onion`, and `dome`. Each profile gets validated controls appropriate to its curve and can share the conical placement, material, and CSG infrastructure. diff --git a/wiki/conical-turret-roof-research.md b/wiki/conical-turret-roof-research.md new file mode 100644 index 0000000000..e52263c14e --- /dev/null +++ b/wiki/conical-turret-roof-research.md @@ -0,0 +1,119 @@ +# Circular tower-cap roof research + +## Recommendation + +Call the first feature **Conical roof** in the UI and `conical` in the roof-type schema. "Turret roof" says where the roof is used, not what shape it has. "Pepperpot" and "candle-snuffer" are picturesque aliases, but official records apply both names to more than one profile. + +The first implementation should be a regular cone on a circular plan. Its editable variations belong to one feature: support diameter, pitch, eave overhang, roof build-up, circular eave trim, material, and optional finial. A steep cone is still a cone. Do not add `pepperpot` or `turret` as separate shape values. + +Bell-cast, ogee, onion, and domed caps are established forms, but they are not pitch settings on a cone. They need curved radial profiles and different controls. Add them later as explicit, discriminated roof profiles. They may share a private surface-of-revolution geometry helper and the same placement/trimming system, but should not be hidden behind a free-form "curvature" slider. + +## What the requested roof is called + +The Getty Art & Architecture Thesaurus defines a [conical roof](https://www.getty.edu/vow/AATFullDisplay?find=drypoint&logic=AND¬e=&subjectid=300411464) as a roof circular in plan that rises to a point as a regular cone. Historic England uses the same term for real tower caps, including a [cylindrical stair turret with a conical roof](https://historicengland.org.uk/listing/the-list/list-entry/1263108) and a [circular stair turret rising from an aisle roof](https://historicengland.org.uk/listing/the-list/list-entry/1191273). + +That gives the product a precise name: + +- UI item: `Conical roof` +- Schema value: `conical` +- Descriptive copy: `A circular roof that rises to a single point, commonly used on towers and turrets.` + +"Conical turret roof" is useful prose when the host matters. It is redundant as the stored shape name. + +## Established circular tower-cap forms + +| Form | Architectural distinction | Product treatment | +|---|---|---| +| Conical roof | Circular plan, straight radial profile, one apex. Getty calls the full shape a regular cone. | Implement now as `conical`. Pitch or rise changes do not create a new type. | +| Bell roof or bell-cast roof | A curved or flared cap whose lower edge opens outward like a bell. The term is plan-independent: Historic England records both [bell-cast roofs on stair towers](https://historicengland.org.uk/listing/the-list/list-entry/1388072) and a [bell-cast pyramidal tower roof](https://historicengland.org.uk/listing/the-list/list-entry/1344559). "Bell-cast" can also describe only the eave flare on an otherwise different roof. | Future explicit profile. Do not model it as cone pitch. Store its flare or control points in a profile-specific object. | +| Ogee roof or ogee dome | Its section uses an ogee, a continuous double curve. Getty's architectural glossary describes an [ogee as a convex and concave S-curve](https://www.getty.edu/publications/resources/virtuallibrary/9780892369812.pdf). Historic England identifies a [ribbed ogee dome on a circular bay](https://historicengland.org.uk/listing/the-list/list-entry/1474405) and an [ogee roof forming a corner turret](https://historicengland.org.uk/listing/the-list/list-entry/1265525). | Future explicit profile. It needs at least an inflection position and upper/lower bulge controls, or a fixed preset with height and flare controls. | +| Ogival roof | Historic descriptions sometimes use "ogivally arched" for a pointed curved cap, as in this [observatory roof with an onion dome above](https://historicengland.org.uk/listing/the-list/list-entry/1201700). Usage is less consistent than `ogee`. | Use `ogee` in the UI. Keep `ogival` as a search alias, not a separate enum value. | +| Onion dome | A pointed bulbous dome. Getty specifies that it is wider than its supporting drum and normally taller than it is wide in the [AAT onion-dome record](https://www.getty.edu/vow/AATFullDisplay?find=nanny&logic=OR¬e=&subjectid=300001285). Historic England records tower onion domes with lanterns and finials, including [Leicester Hebrew Congregation](https://historicengland.org.uk/listing/the-list/list-entry/1389696). | Future explicit profile. Its maximum radius can exceed the host radius, so it needs bulge position and bulge ratio rather than cone pitch. | +| Dome | A spherical or spherical-section roof over a circular, elliptical, or polygonal base in the [Getty AAT definition](https://www.getty.edu/vow/AATFullDisplay?find=&logic=¬e=&subjectid=300001280). Historic England records a [circular lock-up with a domed stone roof](https://historicengland.org.uk/listing/the-list/list-entry/1016741) and a [shallow domed roof on a circular Martello tower](https://historicengland.org.uk/listing/the-list/list-entry/1061124). | Future explicit profile. A dome needs rise or sphere-radius controls and may be shallow, hemispherical, or raised. It should not inherit cone pitch semantics. | +| Cupola | Getty describes a [cupola](https://www.getty.edu/vow/AATFullDisplay?find=Wood&logic=AND¬e=&subjectid=300002230) as a small dome or bulb that crowns a turret, roof, or larger dome. It may sit on pillars or a lantern. This is an appendage or scale/use term, not one outline. | Model later as a hosted roof accessory or small roof assembly. Do not use `cupola` as a conical profile. | +| Pepperpot | Historic England uses "pepperpot" for [lead-domed turrets](https://historicengland.org.uk/listing/the-list/list-entry/1238078), an [ogee slated pepperpot roof](https://historicengland.org.uk/listing/the-list/list-entry/1231513), and a [low stair-turret roof](https://historicengland.org.uk/listing/the-list/list-entry/1303073). It therefore does not identify one geometry. | Search alias and descriptive tag only. Never a roof-type value. | +| Candle-snuffer | Official records use the nickname for a [conical candle-snuffer roof](https://npgallery.nps.gov/GetAsset/d6b818ed-7345-477e-b94a-9aeb58ea6cf5) and for an [ogee-profile candle-snuffer](https://npgallery.nps.gov/GetAsset/5dbd5b06-b23d-44d3-9fda-032eae107a23). | Search alias only. It is no more precise than `pepperpot`. | +| Pyramidal or tented cap | Straight roof faces rise over a square or polygonal plan. It can look similar at a distance but is not circular. | Keep in the hip/pyramidal roof family. A faceted polygonal cap must not silently result from lowering the cone mesh resolution. | + +This list deliberately excludes mansard tower roofs, Rhenish helms, broach spires, and castellated flat tops. They are real tower terminations, but they are not circular-profile caps and do not belong in this feature. + +## Scope of the first feature + +### Geometry + +A regular cone has one architectural profile. In a vertical section, radius decreases linearly from the eave to zero at the apex. The necessary design controls are: + +- circular support diameter or radius; +- pitch, with rise derived from the radius using the same pitch convention as other Pascal roof segments; +- sloped eave overhang; +- wall height or placement elevation; +- deck thickness and covering thickness; +- circular fascia/eave-trim dimensions; +- material assignments for roof surface, edge trim, and wall/body; +- optional finial preset and size. + +The editor should expose pitch, not pitch and rise as two independent stored values. One must derive from the other. The optional finial does not change the roof profile. Historic England records a [circular stair turret with a conical roof and metal finial](https://historicengland.org.uk/listing/the-list/list-entry/1267310), and the National Park Service records a [round turret whose cone terminates in a circular finial](https://npgallery.nps.gov/GetAsset/0bc997ab-a2ca-46b0-9944-23f77ec0002c/). + +Mesh tessellation is a renderer detail, not an architectural setting. If a later design needs an octagonal or twelve-sided cap, that is a polygonal/tented profile with an intentional side count. It is not a low-resolution cone. + +### Fit with the current roof model + +Pascal's [`RoofSegmentNode`](../packages/core/src/schema/nodes/roof-segment.ts) already stores roof type, footprint dimensions, pitch, wall height, deck and covering thickness, overhang, trim, surface materials, and hosted accessories. A cone belongs in that roof family rather than in a new top-level node family. + +There is one important mismatch. A true conical roof has one plan diameter, while the current segment has independent `width` and `depth`. For `conical`, every creation and resize path should maintain `width === depth`, or the schema should add one canonical diameter field. Using `min(width, depth)` or allowing an ellipse would violate the architectural definition and produce surprising resize behavior. + +The existing rectangular `left`, `right`, `front`, and `back` trim distances also cannot describe a round cut. Conical placement needs a circular footprint/cutter, not an approximation made from those four fields. + +## Placement, cutting, and trim behavior + +There are two placement cases and they should remain distinct. + +### Cap on a circular tower or wall + +Snap the cone center to the circular wall center and derive or copy the support diameter. The cone remains intact. Its underside seats on the wall-top ring, and its eave projects beyond that ring by the configured overhang. + +If the wall is independently resizable, define whether the roof follows the wall diameter or keeps a manual diameter. The default should follow the host. A detached/manual mode can keep the roof independent. + +### Turret passing through an existing roof + +When a round tower rises through a larger roof, the tower body is the cutter. Subtract its vertical circular envelope from every intersected sibling roof deck and covering, then union or compose the tower and cap as visible solids. Do not trim the cone against the host roof as the normal case. If the cap intersects the larger roof, the tower is too short or the placement is invalid. + +The cut and flashing must update when the tower moves, its diameter changes, or the host roof changes pitch. The placement system should handle: + +- a circle crossing one or several host roof faces; +- intersections at hips, valleys, and ridges; +- partial circles near an eave or roof boundary; +- deterministic ownership when several roof segments overlap; +- removal of the cut when the turret or cap is deleted; +- selection and movement in both floorplan and 3D views. + +Generate an intersection loop from the circular tower wall against the actual roof surface. Use it for the cut boundary and a later flashing/skirt mesh. A rectangular bounding-box cut will remove too much roof at diagonal slopes. + +The National Park Service's roofing guidance treats the roof as a weathering membrane and calls out careful flashing around a steep conical cupola to promote runoff in [Preservation Brief 4](https://www.nps.gov/orgs/1739/upload/preservation-brief-04-roofing.pdf). That supports modeling the junction as part of placement rather than leaving two intersecting meshes. The same source shows a finial, ribbing, and a lead-coated copper covering, but those are finish/accessory choices, not new roof profiles. + +## Suggested delivery boundary + +Ship the first version with: + +1. `conical` as one new roof type; +2. a locked circular footprint; +3. pitch, diameter, overhang, wall height, thickness, materials, circular eave trim, and optional finial; +4. 2D and 3D placement and resize parity; +5. a round cutter for sibling roof geometry when the tower passes through another roof; +6. placement validation that prevents the cap itself from intersecting the host roof. + +Defer: + +- bell-cast and ogee profiles; +- onion domes; +- spherical and shallow domes; +- lanterns and multi-stage cupolas; +- polygonal/tented caps; +- free-form profile editing; +- structural framing and code claims. + +When curved variants arrive, prefer explicit profile records such as `cone`, `bell`, `ogee`, `onion`, and `dome`, each with its own validated controls. They can live under the same roof-segment node and share placement, materials, cutting, and selection behavior. Separate top-level node types would duplicate the hard parts without adding useful domain meaning. + +## Source-quality note + +Getty AAT supplies controlled architectural definitions. Historic England and National Park Service records show how heritage professionals apply the terms to actual towers and roof details. The product recommendations about schema shape, cutting, and delivery order are engineering conclusions drawn from those definitions and the current Pascal model. From 91ef1ec8e6a1a56c0ce2e9af51a1eff4e5e607c6 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 24 Aug 2026 18:51:15 +0530 Subject: [PATCH 4/9] fix: preserve curved lean-to roof connections --- .../src/schema/nodes/lean-to-extension.ts | 1 + .../lean-to-extension/conical-host.test.ts | 17 ++ .../src/lean-to-extension/conical-host.ts | 7 +- .../src/lean-to-extension/corner-joint.ts | 251 ++++++++++++++---- .../src/lean-to-extension/definition.test.ts | 72 ++++- .../nodes/src/lean-to-extension/definition.ts | 81 +++++- .../src/lean-to-extension/floorplan-tool.tsx | 34 ++- .../nodes/src/lean-to-extension/preview.tsx | 46 +++- .../src/lean-to-extension/roof-corner.test.ts | 82 +++++- packages/nodes/src/lean-to-extension/tool.tsx | 27 +- 10 files changed, 516 insertions(+), 102 deletions(-) diff --git a/packages/core/src/schema/nodes/lean-to-extension.ts b/packages/core/src/schema/nodes/lean-to-extension.ts index 14e1b8afc8..b5f6f0dd02 100644 --- a/packages/core/src/schema/nodes/lean-to-extension.ts +++ b/packages/core/src/schema/nodes/lean-to-extension.ts @@ -30,6 +30,7 @@ export const LeanToExtensionNode = BaseNode.extend({ 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([]), hostKind: LeanToHostKind.default('wall'), + hostHeightOffset: z.number().min(-10).max(10).default(0), span: z.number().min(0.5).max(100).default(4), autoSpan: z.boolean().default(true), diff --git a/packages/nodes/src/lean-to-extension/conical-host.test.ts b/packages/nodes/src/lean-to-extension/conical-host.test.ts index 51a7145450..304ab54608 100644 --- a/packages/nodes/src/lean-to-extension/conical-host.test.ts +++ b/packages/nodes/src/lean-to-extension/conical-host.test.ts @@ -48,6 +48,20 @@ describe('resolveConicalLeanToPlacement', () => { 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', @@ -109,5 +123,8 @@ describe('resolveConicalLeanToPlacement', () => { 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 index 3b64b3c03f..c3a0dc0bbd 100644 --- a/packages/nodes/src/lean-to-extension/conical-host.ts +++ b/packages/nodes/src/lean-to-extension/conical-host.ts @@ -39,7 +39,8 @@ export function resolveConicalLeanToPlacement( if (segment.roofType !== 'conical') return null const radius = segment.width / 2 - const highEdgeHeight = Math.max(0.8, segment.wallHeight) + 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) @@ -48,6 +49,7 @@ export function resolveConicalLeanToPlacement( ...source, parentId: segment.id, hostKind: 'conical-roof', + hostHeightOffset, position: [0, 0, radius], rotation: [0, 0, 0], span: 2 * Math.PI * radius, @@ -126,11 +128,12 @@ 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 (isConicalLeanToHostOccupied(candidate.id, nodes)) 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]) diff --git a/packages/nodes/src/lean-to-extension/corner-joint.ts b/packages/nodes/src/lean-to-extension/corner-joint.ts index 515838120d..c65abe09ec 100644 --- a/packages/nodes/src/lean-to-extension/corner-joint.ts +++ b/packages/nodes/src/lean-to-extension/corner-joint.ts @@ -14,6 +14,7 @@ export type LeanToCornerJoint = { neighborSide: LeanToCornerSide roofExtension: number roofPiece: LeanToPlanPoint[] + roofPieces?: LeanToPlanPoint[][] seam: [LeanToPlanPoint, LeanToPlanPoint] | null beamExtension: number gutterMitre: number @@ -557,6 +558,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, @@ -682,61 +758,142 @@ function resolveCurvedStraightConcaveRoofPiece( candidate: LeanToExtensionNode, candidateWall: WallNode, candidateSide: LeanToCornerSide, -): { piece: LeanToPlanPoint[]; seam: [LeanToPlanPoint, LeanToPlanPoint] | null } | null { +): { + piece: LeanToPlanPoint[] + pieces: LeanToPlanPoint[][] + seam: [LeanToPlanPoint, LeanToPlanPoint] | null +} | null { const ownCurved = isCurvedLeanTo(leanTo) const candidateCurved = isCurvedLeanTo(candidate) if (ownCurved === candidateCurved) return null - const direct = resolveConcaveRoofPiece(leanTo, wall, side, candidate, candidateWall) - if ((direct.piece.length >= 3 && direct.seam) || !ownCurved) 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) - // At a semicircle the curved parameter-space boundary can touch the same - // roof plane at both ends, while the straight neighbor still yields the seam. - const reciprocal = resolveConcaveRoofPiece( - candidate, - candidateWall, - candidateSide, - leanTo, - wall, - ) - if (!reciprocal.seam) return null - const seamWorld = reciprocal.seam.map((point) => - leanToPointToWorld(candidateWall, candidate, point[0], point[1]), + 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 (seamWorld.some((point) => !point)) return null - const localized = seamWorld.map((point) => worldPointToLeanTo(wall, leanTo, point!)) - if (localized.some((point) => !point)) return null + 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 layout = resolveLeanToLayout(leanTo) - const edges = roofPlanEdges(leanTo) - const [backSeam, frontSeam] = (localized as LeanToPlanPoint[]).sort( - (left, right) => left[1] - right[1], - ) as [LeanToPlanPoint, LeanToPlanPoint] - const leftX = layout.roofCenterX - layout.roofWidth / 2 - const rightX = layout.roofCenterX + layout.roofWidth / 2 - const piece: LeanToPlanPoint[] = - side === 'left' - ? [backSeam, [rightX, edges.back], [rightX, edges.front], frontSeam] - : [[leftX, edges.back], backSeam, frontSeam, [leftX, edges.front]] + // 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 retainedOverlap = overlaps.flatMap((overlap) => { + const piece = clipToRetainedRoofSide(overlap, worldHeightDelta, -curvedRetainedSign) + return piece.length >= 3 ? [piece] : [] + }) + retainedWorld = [...exclusive, ...retainedOverlap] + } - return { piece, seam: [backSeam, frontSeam] } + const seamWorld: LeanToPlanPoint[] = [] + for (const overlap of overlaps) { + for (let index = 0; index < overlap.length; index++) { + const current = overlap[index]! + const next = overlap[(index + 1) % overlap.length]! + const currentDelta = worldHeightDelta(current) + const nextDelta = worldHeightDelta(next) + if (currentDelta === null || nextDelta === null) continue + if (Math.abs(currentDelta) <= PLAN_TOLERANCE) seamWorld.push(current) + if (currentDelta * nextDelta >= 0) continue + const ratio = currentDelta / (currentDelta - nextDelta) + seamWorld.push([ + current[0] + (next[0] - current[0]) * ratio, + current[1] + (next[1] - current[1]) * ratio, + ]) + } + } + const uniqueSeam = seamWorld.filter( + (point, index) => + seamWorld.findIndex( + (candidatePoint) => planDistance(point, candidatePoint) <= PLAN_TOLERANCE, + ) === index, + ) + let seamEndpoints: [LeanToPlanPoint, LeanToPlanPoint] | null = null + for (const first of uniqueSeam) { + for (const second of uniqueSeam) { + if (!seamEndpoints || planDistance(first, second) > planDistance(...seamEndpoints)) { + seamEndpoints = [first, second] + } + } + } + const pieces = retainedWorld.flatMap((polygon) => { + const localized = polygon.map((point) => worldPointToLeanTo(wall, leanTo, point)) + if (localized.some((point) => !point)) return [] + const piece = localized as LeanToPlanPoint[] + return piece.length >= 3 && Math.abs(polygonSignedArea(piece)) > PLAN_TOLERANCE ? [piece] : [] + }) + const localizedSeam = seamEndpoints?.map((point) => worldPointToLeanTo(wall, leanTo, point)) + const seam = + localizedSeam?.[0] && localizedSeam[1] + ? ([localizedSeam[0], localizedSeam[1]] as [LeanToPlanPoint, LeanToPlanPoint]) + : null + if (pieces.length === 0 || !seam) return null + + return { piece: pieces[0]!, pieces, seam } } export function applyLeanToCornerRoofPieces( base: LeanToPlanPoint[], joints: Partial>, ): LeanToPlanPoint[][] { - let retained = base + let retained = [base] const additions: LeanToPlanPoint[][] = [] for (const side of ['left', 'right'] as const) { const joint = joints[side] if (!joint || joint.roofPiece.length < 3) continue if (joint.kind === 'concave') { - retained = intersectConvexPolygons(retained, joint.roofPiece) + const clips = joint.roofPieces ?? [joint.roofPiece] + retained = retained.flatMap((subject) => + clips.flatMap((clip) => { + const intersection = intersectConvexPolygons(subject, clip) + return intersection.length >= 3 && + Math.abs(polygonSignedArea(intersection)) > PLAN_TOLERANCE + ? [intersection] + : [] + }), + ) } else { additions.push(joint.roofPiece) } } - return [...(retained.length >= 3 ? [retained] : []), ...additions] + return [...retained, ...additions] } function resolveRoofPiece( @@ -1010,19 +1167,20 @@ export function resolveLeanToCornerJoints( candidateWall, ) : resolveConcaveRoofPiece(cornerLeanTo, wall, side, cornerCandidate, candidateWall)) - const seam = curvedStraightRoof || curvedStraightConcaveRoof - ? roof.seam - : sharedRoofSeam( - wall, - cornerLeanTo, - side, - roofExtension, - candidateWall, - cornerCandidate, - neighborSide, - candidateRoofExtension, - kind, - ) + const seam = + curvedStraightRoof || curvedStraightConcaveRoof + ? roof.seam + : sharedRoofSeam( + wall, + cornerLeanTo, + side, + roofExtension, + candidateWall, + cornerCandidate, + neighborSide, + candidateRoofExtension, + kind, + ) const beamExtension = curvedConcaveJoint ? 0 : (extensionToRunIntersection( @@ -1061,6 +1219,7 @@ export function resolveLeanToCornerJoints( neighborSide, roofExtension, roofPiece: roof.piece, + roofPieces: curvedStraightConcaveRoof?.pieces, seam: seam ?? roof.seam, beamExtension, gutterMitre: (kind === 'concave' ? -1 : 1) * ((Math.PI - gutterInteriorAngle) / 2), diff --git a/packages/nodes/src/lean-to-extension/definition.test.ts b/packages/nodes/src/lean-to-extension/definition.test.ts index f1d87652e7..ad48925bce 100644 --- a/packages/nodes/src/lean-to-extension/definition.test.ts +++ b/packages/nodes/src/lean-to-extension/definition.test.ts @@ -5,8 +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' @@ -48,6 +50,13 @@ function heightHandle(): 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 => @@ -81,12 +90,65 @@ describe('lean-to extension span handles', () => { ]) }) - test('hides host-controlled span and height arrows on a closed conical loop', () => { - const circular = node({ hostKind: 'conical-roof' }) + 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(spanHandle('min').visible?.(circular, undefined as never)).toBe(false) - expect(spanHandle('max').visible?.(circular, undefined as never)).toBe(false) - expect(heightHandle().visible?.(circular, undefined as never)).toBe(false) + 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', () => { diff --git a/packages/nodes/src/lean-to-extension/definition.ts b/packages/nodes/src/lean-to-extension/definition.ts index 3c178fc184..765ebcebeb 100644 --- a/packages/nodes/src/lean-to-extension/definition.ts +++ b/packages/nodes/src/lean-to-extension/definition.ts @@ -1,8 +1,10 @@ import { + type AnyNode, type AnyNodeId, findLevelAncestorId, type HandleDescriptor, type NodeDefinition, + type RoofSegmentNode, type SceneApi, type WallNode, } from '@pascal-app/core' @@ -36,6 +38,12 @@ 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 const wall = sceneApi.get(node.parentId as AnyNodeId) @@ -104,6 +112,19 @@ function highEdgeHeightPatch( newValue: number, sceneApi: SceneApi, ): Partial { + 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()) @@ -149,7 +170,6 @@ function highEdgeHeightHandle(): HandleDescriptor { apply: highEdgeHeightPatch, previewOverrides: (node, newValue, sceneApi) => leanToManagedPreviewOverrides(node, highEdgeHeightPatch(node, newValue, sceneApi), sceneApi), - visible: (node) => node.hostKind !== 'conical-roof', onDrag: publishAdjacentHeightGuide, onDragEnd: (node) => clearStructuralElevationGuide(node.id), placement: { @@ -288,6 +308,62 @@ function spanHandle(side: 'left' | 'right'): HandleDescriptor { + 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', + } +} + const leanToExtensionHandles: HandleDescriptor[] = [ highEdgeHeightHandle(), pitchHandle(), @@ -312,10 +388,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: 8, + schemaVersion: 9, schema: LeanToExtensionNode, category: 'structure', snapProfile: 'structural', diff --git a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx index 980f670ec7..06194c36a7 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx +++ b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx @@ -13,7 +13,11 @@ 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 } from './conical-host' +import { + type ConicalLeanToPlanHost, + findConicalLeanToHostInPlan, + isConicalLeanToHostOccupied, +} from './conical-host' import { leanToFacetCount } from './geometry' import { resolveLeanToSpanArc, resolveLeanToWallPlacement } from './layout' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' @@ -30,6 +34,7 @@ import type { LeanToExtensionNode } from './schema' type PlanPoint = [number, number] type PlanTarget = { node: LeanToExtensionNode + valid: boolean conicalHost?: ConicalLeanToPlanHost } @@ -71,8 +76,16 @@ const FloorplanLeanToExtensionTool = ({ const point = clientToPlanPoint(group, event.clientX, event.clientY) if (!point) return null const nodes = sceneApi.nodes() as Record - const conicalHost = findConicalLeanToHostInPlan(point, nodes, activeLevelId) - if (conicalHost) return { node: conicalHost.node, conicalHost } + const conicalHost = findConicalLeanToHostInPlan(point, nodes, activeLevelId, { + includeOccupied: true, + }) + if (conicalHost) { + return { + node: conicalHost.node, + valid: !isConicalLeanToHostOccupied(conicalHost.segment.id, nodes), + conicalHost, + } + } const hit = findClosestWallInPlan(point, nodes, activeLevelId) if (!hit) return null const wallPlacement = resolveLeanToWallPlacement(hit.wall, hit.localX, hit.side) @@ -88,7 +101,10 @@ const FloorplanLeanToExtensionTool = ({ wallPlacement.position[0], ) const node = resolveLeanToEndAbutments(attachedNode, hit.wall, nodes) - return leanToPlacementConflicts(node, hit.wall, nodes).length === 0 ? { node } : null + return { + node, + valid: leanToPlacementConflicts(node, hit.wall, nodes).length === 0, + } } const update = (event: PointerEvent) => { consume(event) @@ -103,7 +119,7 @@ const FloorplanLeanToExtensionTool = ({ if (event.button !== 0) return consume(event) const resolved = resolveEvent(event) ?? targetRef.current - if (!resolved) return + if (!resolved?.valid) return const { node } = resolved const nodes = sceneApi.nodes() as Record const assembly = createLeanToAssembly(node, resolveLeanToHostRoof(node, nodes), nodes) @@ -168,11 +184,11 @@ const FloorplanLeanToExtensionTool = ({ return ( point.join(',')).join(' ')} - stroke="#0ea5e9" + stroke={target.valid ? '#0ea5e9' : '#ef4444'} strokeDasharray="6 4" strokeWidth={2} vectorEffect="non-scaling-stroke" @@ -257,10 +273,10 @@ const FloorplanLeanToExtensionTool = ({ return ( point.join(',')).join(' ')} - stroke="#0ea5e9" + stroke={target.valid ? '#0ea5e9' : '#ef4444'} strokeDasharray="6 4" strokeWidth={2} vectorEffect="non-scaling-stroke" diff --git a/packages/nodes/src/lean-to-extension/preview.tsx b/packages/nodes/src/lean-to-extension/preview.tsx index 424669095c..7907962fa6 100644 --- a/packages/nodes/src/lean-to-extension/preview.tsx +++ b/packages/nodes/src/lean-to-extension/preview.tsx @@ -4,10 +4,17 @@ 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 { Color, type Material, Mesh } from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildLeanToExtensionGeometry } from './geometry' -const LeanToExtensionPreview = ({ node }: { node: LeanToExtensionNode }) => { +const LeanToExtensionPreview = ({ + node, + invalid, +}: { + node: LeanToExtensionNode + invalid?: boolean +}) => { const shading = useViewer((state) => state.shading) const colorPreset = useViewer((state) => state.colorPreset) const sceneTheme = useViewer((state) => state.sceneTheme) @@ -17,30 +24,47 @@ const LeanToExtensionPreview = ({ node }: { node: LeanToExtensionNode }) => { ) useEffect(() => { + const originals: Array<{ mesh: Mesh; material: Material | Material[] }> = [] const ownedMaterials: Material[] = [] built.traverse((object) => { object.layers.set(EDITOR_LAYER) - ;(object as unknown as { raycast: () => void }).raycast = () => {} - const mesh = object as { material?: Material | Material[] } - if (!mesh.material) return - const clone = (material: Material) => { + object.raycast = () => {} + if (!(object instanceof Mesh)) return + originals.push({ mesh: object, material: object.material }) + const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material] + const materials = sourceMaterials.map((material) => { const copy = material.clone() copy.transparent = true - copy.opacity = 0.5 + copy.opacity = invalid ? 0.4 : 0.5 copy.depthWrite = false + if (invalid) { + if ('color' in copy && copy.color instanceof Color) { + copy.color.setHex(INVALID_GHOST_COLOR) + } + if ('emissive' in copy && copy.emissive instanceof Color) { + copy.emissive.setHex(INVALID_GHOST_COLOR) + } + } ownedMaterials.push(copy) return copy - } - mesh.material = Array.isArray(mesh.material) ? mesh.material.map(clone) : clone(mesh.material) + }) + object.material = Array.isArray(object.material) ? materials : materials[0]! }) return () => { + for (const { mesh, material } of originals) mesh.material = material for (const material of ownedMaterials) material.dispose() + } + }, [built, invalid]) + + useEffect( + () => () => { built.traverse((object) => { const mesh = object as { geometry?: { dispose: () => void } } mesh.geometry?.dispose() }) - } - }, [built]) + }, + [built], + ) return } 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 bd02027ee5..351595de17 100644 --- a/packages/nodes/src/lean-to-extension/roof-corner.test.ts +++ b/packages/nodes/src/lean-to-extension/roof-corner.test.ts @@ -786,7 +786,7 @@ describe('lean-to corner joint', () => { parentId: 'level_inward_chain', start: [0, 0], end: [6, 0], - curveOffset: -3, + curveOffset: 3, }) const wallC = WallNode.parse({ id: 'wall_inward_chain_right', @@ -796,21 +796,21 @@ describe('lean-to corner joint', () => { }) const leanToA = { ...applyLeanToWallAutoSpan( - resolveLeanToWallPlacement(wallA, getWallCurveLength(wallA) / 2, 'front')!, + resolveLeanToWallPlacement(wallA, getWallCurveLength(wallA) / 2, 'back')!, wallA, ), id: 'leanto_inward_chain_left', } const leanToB = { ...applyLeanToWallAutoSpan( - resolveLeanToWallPlacement(wallB, getWallCurveLength(wallB) / 2, 'front')!, + resolveLeanToWallPlacement(wallB, getWallCurveLength(wallB) / 2, 'back')!, wallB, ), id: 'leanto_inward_chain_center', } const leanToC = { ...applyLeanToWallAutoSpan( - resolveLeanToWallPlacement(wallC, getWallCurveLength(wallC) / 2, 'front')!, + resolveLeanToWallPlacement(wallC, getWallCurveLength(wallC) / 2, 'back')!, wallC, ), id: 'leanto_inward_chain_right', @@ -823,18 +823,19 @@ describe('lean-to corner joint', () => { const jointsB = resolveLeanToCornerJoints(leanToB, wallB, nodes) const jointsC = resolveLeanToCornerJoints(leanToC, wallC, nodes) - expect(jointsB.left?.neighborId).toBe(leanToA.id) - expect(jointsB.right?.neighborId).toBe(leanToC.id) + 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.left, jointsA.right, wallB, leanToB, wallA, leanToA], - [jointsB.right, jointsC.left, wallB, leanToB, wallC, leanToC], + [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 ownSeam = own?.seam?.map((point) => cornerPlanPointToWorld(ownWall, ownLeanTo, point)) const reciprocalSeam = reciprocal?.seam?.map((point) => cornerPlanPointToWorld(reciprocalWall, reciprocalLeanTo, point), ) @@ -844,8 +845,63 @@ describe('lean-to corner joint', () => { } const centerAssembly = createLeanToAssembly(leanToB, undefined, nodes) - expect(centerAssembly.segment.shedFootprintPieces).toHaveLength(1) - expect(centerAssembly.segment.shedFootprintPieces?.[0]).toHaveLength(5) + 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 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) { + 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(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('resolves a reciprocal 60 degree corner with its true gutter mitre', () => { diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx index 5942060f88..b9182a91d0 100644 --- a/packages/nodes/src/lean-to-extension/tool.tsx +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -46,6 +46,7 @@ type PreviewPose = { node: LeanToExtensionNode position: [number, number, number] rotationY: number + valid: boolean } const LeanToExtensionTool = () => { @@ -86,6 +87,7 @@ const LeanToExtensionTool = () => { node: LeanToExtensionNode, localPosition: readonly [number, number, number], extraRotationY = 0, + valid = true, ): PreviewPose => { const position = event.object.localToWorld(new Vector3(...localPosition)) const rotationY = @@ -95,22 +97,20 @@ const LeanToExtensionTool = () => { node, position: [position.x, position.y, position.z], rotationY, + valid, } } const updateConicalSegmentTarget = (event: RoofSegmentEvent) => { const nodes = sceneApi.nodes() as Record - if (isConicalLeanToHostOccupied(event.node.id, nodes)) { - setPreview(null) - return null - } const node = resolveConicalLeanToSurfaceHit(event.node, event.localPosition, event.normal) if (!node) { setPreview(null) return null } - setPreview(worldPreviewPose(event, node, node.position)) - return node + const valid = !isConicalLeanToHostOccupied(event.node.id, nodes) + setPreview(worldPreviewPose(event, node, node.position, 0, valid)) + return valid ? node : null } const updateConicalRoofTarget = (event: RoofEvent) => { @@ -119,7 +119,6 @@ const LeanToExtensionTool = () => { for (const childId of event.node.children) { const segment = nodes[childId as AnyNodeId] if (segment?.type !== 'roof-segment' || segment.roofType !== 'conical') continue - if (isConicalLeanToHostOccupied(segment.id, nodes)) continue const cos = Math.cos(segment.rotation) const sin = Math.sin(segment.rotation) const dx = event.localPosition[0] - segment.position[0] @@ -139,6 +138,7 @@ const LeanToExtensionTool = () => { : undefined const node = resolveConicalLeanToSurfaceHit(segment, localPosition, normal) if (!node) continue + const valid = !isConicalLeanToHostOccupied(segment.id, nodes) 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( @@ -147,9 +147,10 @@ const LeanToExtensionTool = () => { node, [crownX, segment.position[1] + node.position[1], crownZ], segment.rotation, + valid, ), ) - return node + return valid ? node : null } setPreview(null) return null @@ -178,10 +179,7 @@ const LeanToExtensionTool = () => { wallPlacement.position[0], ) const node = resolveLeanToEndAbutments(attachedNode, event.node, nodes) - if (leanToPlacementConflicts(node, event.node, nodes).length > 0) { - setPreview(null) - return null - } + const valid = leanToPlacementConflicts(node, event.node, nodes).length === 0 const pose = leanToWallLocalPose(event.node, node, resolveBaseY(event.node)) setPreview((current) => ({ node: @@ -189,8 +187,9 @@ const LeanToExtensionTool = () => { ? current.node : node, ...pose, + valid, })) - return node + return valid ? node : null } const onWallMove = (event: WallEvent) => { @@ -298,7 +297,7 @@ const LeanToExtensionTool = () => { if (!preview || viewMode !== '3d') return null return ( - + ) } From af989369f5dac509f63b0cdca405d49f916e44a7 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 25 Aug 2026 00:17:10 +0530 Subject: [PATCH 5/9] fix roof and dormer editing behavior --- apps/editor/components/build-tab.tsx | 20 +-- apps/editor/lib/build-tab-state.test.ts | 16 +- apps/editor/lib/build-tab-state.ts | 7 - packages/core/src/schema/nodes/dormer.ts | 6 +- .../src/dormer/__tests__/geometry.test.ts | 76 +++++++- .../nodes/src/dormer/__tests__/schema.test.ts | 5 + packages/nodes/src/dormer/csg-geometry.ts | 23 +-- packages/nodes/src/dormer/definition.ts | 2 +- packages/nodes/src/dormer/floorplan.ts | 13 +- packages/nodes/src/dormer/geometry.ts | 8 +- packages/nodes/src/dormer/panel.tsx | 33 +++- packages/nodes/src/dormer/parametrics.ts | 7 + packages/nodes/src/dormer/preview.tsx | 10 +- packages/nodes/src/dormer/renderer.tsx | 1 + .../src/lean-to-extension/assembly.test.ts | 44 +++++ .../nodes/src/lean-to-extension/assembly.ts | 29 +++- .../src/lean-to-extension/corner-joint.ts | 69 ++++++++ .../lean-to-extension/placement-scope.test.ts | 52 ++++++ .../src/lean-to-extension/placement-scope.ts | 9 + .../src/lean-to-extension/roof-corner.test.ts | 147 +++++++++++++++- packages/nodes/src/lean-to-extension/tool.tsx | 19 +- .../nodes/src/roof-segment/definition.test.ts | 24 ++- packages/nodes/src/roof-segment/definition.ts | 26 ++- .../floorplan-affordances.test.ts | 59 +++++++ .../src/roof-segment/floorplan-affordances.ts | 36 +++- .../nodes/src/roof-segment/floorplan.test.ts | 3 + packages/nodes/src/roof-segment/floorplan.ts | 47 +++-- .../src/systems/roof/roof-system.test.ts | 39 ++++- .../viewer/src/systems/roof/roof-system.tsx | 162 +++++++++++++++--- 29 files changed, 872 insertions(+), 120 deletions(-) create mode 100644 packages/nodes/src/lean-to-extension/placement-scope.test.ts create mode 100644 packages/nodes/src/lean-to-extension/placement-scope.ts create mode 100644 packages/nodes/src/roof-segment/floorplan-affordances.test.ts diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 32b9275776..f17a293020 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,6 +1,6 @@ 'use client' -import { nodeRegistry, type RoofType, useRegistryVersion } from '@pascal-app/core' +import { nodeRegistry, useRegistryVersion } from '@pascal-app/core' import { type FloorplanMode, getFloorplanNodeExtension, @@ -175,20 +175,12 @@ type RoofFeature = { label: string iconSrc: string kind?: string - roofType?: RoofType } const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp' function collectRoofFeatures(): RoofFeature[] { - const features: RoofFeature[] = [ - { - id: 'roof-shape:conical', - label: 'Conical roof', - iconSrc: ROOF_FEATURE_FALLBACK_ICON, - roofType: 'conical', - }, - ] + const features: RoofFeature[] = [] for (const [kind, def] of nodeRegistry.entries()) { if ( def.capabilities.roofAccessory === undefined && @@ -222,11 +214,6 @@ function activateRoofFeatureTool(feature: RoofFeature): void { ed.setStructureLayer('elements') ed.setCatalogCategory(null) ed.setMode('build') - if (feature.roofType) { - ed.setToolDefaults('roof', { roofType: feature.roofType }) - ed.setTool('roof') - return - } if (feature.kind) ed.setTool(feature.kind) } @@ -247,7 +234,6 @@ const MEP_TOOL_KINDS = new Set([ export function BuildTab() { const activeTool = useEditor((s) => s.tool) - const activeRoofType = useEditor((s) => s.toolDefaults.roof?.roofType) const mode = useEditor((s) => s.mode) const floorplanMode = useFloorplanMode((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) @@ -289,7 +275,7 @@ 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 activeRoofFeatureId = getActiveRoofFeatureId(roofFeatures, activeTool, activeRoofType) + const activeRoofFeatureId = getActiveRoofFeatureId(roofFeatures, activeTool) const isRoofFeatureActive = mode === 'build' && activeRoofFeatureId !== null const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool) diff --git a/apps/editor/lib/build-tab-state.test.ts b/apps/editor/lib/build-tab-state.test.ts index c8079cc631..927075618f 100644 --- a/apps/editor/lib/build-tab-state.test.ts +++ b/apps/editor/lib/build-tab-state.test.ts @@ -2,26 +2,22 @@ import { describe, expect, test } from 'bun:test' import { getActiveRoofFeatureId, type RoofFeatureIdentity } from './build-tab-state' const FEATURES: RoofFeatureIdentity[] = [ - { id: 'roof-shape:conical', roofType: 'conical' }, { 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', undefined)).toBeNull() + expect(getActiveRoofFeatureId(FEATURES, 'roof')).toBeNull() }) - test('selects exactly the matching roof shape or accessory', () => { - expect(getActiveRoofFeatureId(FEATURES, 'roof', 'conical')).toBe('roof-shape:conical') - expect(getActiveRoofFeatureId(FEATURES, 'lean-to-extension', undefined)).toBe( - 'lean-to-extension', - ) + 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, roofType }) => ({ id, roofType })) - expect(getActiveRoofFeatureId(malformed, undefined, undefined)).toBeNull() - expect(getActiveRoofFeatureId(malformed, 'skylight', undefined)).toBeNull() + const malformed = FEATURES.map(({ id }) => ({ id })) + expect(getActiveRoofFeatureId(malformed, undefined)).toBeNull() + expect(getActiveRoofFeatureId(malformed, 'skylight')).toBeNull() }) }) diff --git a/apps/editor/lib/build-tab-state.ts b/apps/editor/lib/build-tab-state.ts index 8020a1a50a..66e8862d87 100644 --- a/apps/editor/lib/build-tab-state.ts +++ b/apps/editor/lib/build-tab-state.ts @@ -1,19 +1,12 @@ export type RoofFeatureIdentity = { id: string kind?: string - roofType?: string } export function getActiveRoofFeatureId( features: readonly RoofFeatureIdentity[], activeTool: string | null | undefined, - activeRoofType: unknown, ): string | null { - if (activeTool === 'roof') { - if (typeof activeRoofType !== 'string' || activeRoofType.length === 0) return null - return features.find((feature) => feature.roofType === activeRoofType)?.id ?? null - } - if (!activeTool) return null return features.find((feature) => feature.kind === activeTool)?.id ?? null } diff --git a/packages/core/src/schema/nodes/dormer.ts b/packages/core/src/schema/nodes/dormer.ts index 7559578775..a961cbc68b 100644 --- a/packages/core/src/schema/nodes/dormer.ts +++ b/packages/core/src/schema/nodes/dormer.ts @@ -75,6 +75,7 @@ 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 @@ -111,8 +112,9 @@ export const DormerNode = BaseNode.extend({ 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. WindowNode children - are hosted on its wall faces and use the regular window item model. + 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. `, ) diff --git a/packages/nodes/src/dormer/__tests__/geometry.test.ts b/packages/nodes/src/dormer/__tests__/geometry.test.ts index 4ba74f659f..357c98288a 100644 --- a/packages/nodes/src/dormer/__tests__/geometry.test.ts +++ b/packages/nodes/src/dormer/__tests__/geometry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import { getRoofSegmentSurfaceY, type RoofSegmentNode, type RoofType } from '@pascal-app/core' -import { getDormerExposedFaces } from '../csg-geometry' +import { buildDormerRoofCut, getDormerExposedFaces } from '../csg-geometry' import { buildDormerGhostGeometry, dormerSupportsArch, @@ -30,6 +30,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,6 +104,40 @@ describe('buildDormerGhostGeometry (placement preview)', () => { }) }) +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() + }) +}) + describe('windowShape predicates', () => { test('dormerSupportsArch only when windowShape=arch', () => { expect(dormerSupportsArch(DormerNode.parse({ windowShape: 'arch' }))).toBe(true) 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 f4f69f5d28..a4bc7d9b66 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -23,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). @@ -256,7 +256,7 @@ export function generateDormerGeometry( 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) @@ -531,10 +531,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 @@ -813,10 +813,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 c261e46d18..21e6b3384e 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -448,7 +448,7 @@ const dormerHandles: HandleDescriptor[] = [ */ export const dormerDefinition: NodeDefinition = { kind: 'dormer', - schemaVersion: 2, + schemaVersion: 3, schema: DormerNode, category: 'structure', surfaceRole: 'roof', diff --git a/packages/nodes/src/dormer/floorplan.ts b/packages/nodes/src/dormer/floorplan.ts index a4abe3414f..e8c886e753 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 diff --git a/packages/nodes/src/dormer/geometry.ts b/packages/nodes/src/dormer/geometry.ts index a13b290536..f276a866c4 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 } diff --git a/packages/nodes/src/dormer/panel.tsx b/packages/nodes/src/dormer/panel.tsx index eb36b57c05..f8d588cb1b 100644 --- a/packages/nodes/src/dormer/panel.tsx +++ b/packages/nodes/src/dormer/panel.tsx @@ -30,6 +30,7 @@ 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 }> = [ @@ -42,6 +43,11 @@ 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: 'Windows', value: 'window' }, @@ -322,7 +328,7 @@ export default function DormerPanel() { value={Math.round(node.height * 100) / 100} /> previewProp({ roofHeight: v })} @@ -357,6 +363,31 @@ export default function DormerPanel() { })} + + {node.roofType === 'shed' && ( + +
+ {SHED_HIGH_SIDE_OPTIONS.map((option) => { + const isSelected = node.shedHighSide === option.value + return ( + + ) + })} +
+
+ )} )} diff --git a/packages/nodes/src/dormer/parametrics.ts b/packages/nodes/src/dormer/parametrics.ts index d8a46cccf1..a8733a5f53 100644 --- a/packages/nodes/src/dormer/parametrics.ts +++ b/packages/nodes/src/dormer/parametrics.ts @@ -26,6 +26,13 @@ export const dormerParametrics: ParametricDescriptor = { display: 'select', }, { key: 'roofHeight', kind: 'number', unit: 'm', min: 0, max: 2, step: 0.05 }, + { + key: 'shedHighSide', + kind: 'enum', + options: ['back', 'front'], + display: 'segmented', + visibleIf: (n) => n.roofType === 'shed', + }, ], }, { 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 8788a4a771..c6733db467 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -141,6 +141,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { segment, node.id, node.roofType, + node.shedHighSide, node.width, node.depth, node.height, diff --git a/packages/nodes/src/lean-to-extension/assembly.test.ts b/packages/nodes/src/lean-to-extension/assembly.test.ts index 1ec83c15b7..10f9ca8f29 100644 --- a/packages/nodes/src/lean-to-extension/assembly.test.ts +++ b/packages/nodes/src/lean-to-extension/assembly.test.ts @@ -606,6 +606,50 @@ describe('lean-to assembly', () => { ) }) + test('extends upper-storey pillars through open space to site ground', () => { + const building = BuildingNode.parse({ + id: 'building_upper_post', + children: ['level_lower_post', 'level_upper_post'], + }) + const lower = LevelNode.parse({ + id: 'level_lower_post', + parentId: building.id, + level: 0, + height: 3, + }) + const upper = LevelNode.parse({ + id: 'level_upper_post', + parentId: building.id, + level: 1, + height: 3, + children: ['wall_upper_post'], + }) + const wall = WallNode.parse({ + id: 'wall_upper_post', + parentId: upper.id, + start: [-2, 0], + end: [2, 0], + thickness: 0.1, + }) + const leanTo = LeanToExtensionNode.parse({ + parentId: wall.id, + position: [2, 0, wall.thickness / 2], + projection: 2.5, + }) + const nodes = Object.fromEntries( + [building, lower, upper, wall, leanTo].map((node) => [node.id, node]), + ) as Record + + const baseY = resolveLeanToPostBaseY(leanTo, wall, nodes, 0) + const post = leanToPostLayoutPatch(leanTo, 0, baseY) + + expect(post.position[1]).toBeCloseTo(-3.02, 6) + expect(post.position[1] + post.height).toBeCloseTo( + resolveLeanToLayout(leanTo).postHeight + 0.02, + 6, + ) + }) + test('keeps a swapped pillar beneath the beam while its shaft clears the gutter', () => { const leanTo = LeanToExtensionNode.parse({ lowOverhang: 0.25, projection: 2.5 }) const swapped = { diff --git a/packages/nodes/src/lean-to-extension/assembly.ts b/packages/nodes/src/lean-to-extension/assembly.ts index 3cc4c3c1dd..f817d29e10 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,6 +18,7 @@ import { RoofSegmentNode, type RoofSegmentNode as RoofSegmentNodeType, spatialGridManager, + terrainFieldOf, type WallNode, } from '@pascal-app/core' import { resolveEaveSnap } from '../gutter/eave-snap' @@ -223,6 +226,30 @@ 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, @@ -269,7 +296,7 @@ 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 diff --git a/packages/nodes/src/lean-to-extension/corner-joint.ts b/packages/nodes/src/lean-to-extension/corner-joint.ts index c65abe09ec..6138717988 100644 --- a/packages/nodes/src/lean-to-extension/corner-joint.ts +++ b/packages/nodes/src/lean-to-extension/corner-joint.ts @@ -524,6 +524,60 @@ function polygonSignedArea(polygon: readonly LeanToPlanPoint[]): number { return area / 2 } +function pointInPlanPolygon( + point: readonly [number, number], + polygon: readonly LeanToPlanPoint[], +): boolean { + let inside = false + for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { + const current = polygon[index]! + const prior = polygon[previous]! + const edgeX = current[0] - prior[0] + const edgeZ = current[1] - prior[1] + const cross = (point[0] - prior[0]) * edgeZ - (point[1] - prior[1]) * edgeX + const dot = + (point[0] - prior[0]) * (point[0] - current[0]) + + (point[1] - prior[1]) * (point[1] - current[1]) + if (Math.abs(cross) <= PLAN_TOLERANCE && dot <= PLAN_TOLERANCE) return true + if ( + current[1] > point[1] !== prior[1] > point[1] && + point[0] < + ((prior[0] - current[0]) * (point[1] - current[1])) / (prior[1] - current[1]) + current[0] + ) { + inside = !inside + } + } + return inside +} + +function connectedPlanPolygonComponent( + polygons: LeanToPlanPoint[][], + probe: readonly [number, number], +): LeanToPlanPoint[][] { + const connected = new Set() + const queue = polygons.flatMap((polygon, index) => + pointInPlanPolygon(probe, polygon) ? [index] : [], + ) + for (const index of queue) connected.add(index) + + while (queue.length > 0) { + const currentIndex = queue.shift()! + const current = polygons[currentIndex]! + for (let candidateIndex = 0; candidateIndex < polygons.length; candidateIndex++) { + if (connected.has(candidateIndex)) continue + const candidate = polygons[candidateIndex]! + const touches = + current.some((point) => pointInPlanPolygon(point, candidate)) || + candidate.some((point) => pointInPlanPolygon(point, current)) + if (!touches) continue + connected.add(candidateIndex) + queue.push(candidateIndex) + } + } + + return polygons.filter((_, index) => connected.has(index)) +} + function intersectConvexPolygons( subject: readonly LeanToPlanPoint[], clip: readonly LeanToPlanPoint[], @@ -791,6 +845,19 @@ function resolveCurvedStraightConcaveRoofPiece( 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. @@ -815,6 +882,8 @@ function resolveCurvedStraightConcaveRoofPiece( 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] : [] 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/roof-corner.test.ts b/packages/nodes/src/lean-to-extension/roof-corner.test.ts index 351595de17..909dabbd1f 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, @@ -217,7 +218,7 @@ function getSegmentSlopeFrameForTest(segment: ReturnType, leanTo: ReturnType, @@ -880,12 +900,19 @@ describe('lean-to corner joint', () => { (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 @@ -899,11 +926,125 @@ describe('lean-to corner joint', () => { } 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) @@ -1140,8 +1281,8 @@ describe('lean-to corner joint', () => { assertTopGeometryFollowsRoofSlab(localGeometries[0]!, segmentA) assertTopGeometryFollowsRoofSlab(localGeometries[1]!, segmentB) - expect(countTopMaterialVerticalTriangles(localGeometries[0]!)).toBe(0) - expect(countTopMaterialVerticalTriangles(localGeometries[1]!)).toBe(0) + expect(countTopMaterialNonUpwardTriangles(localGeometries[0]!)).toBe(0) + expect(countTopMaterialNonUpwardTriangles(localGeometries[1]!)).toBe(0) const meshes = [ new THREE.Mesh( diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx index b9182a91d0..3b97c1c0ee 100644 --- a/packages/nodes/src/lean-to-extension/tool.tsx +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -29,6 +29,7 @@ import { resolveLeanToWallPlacement, resolveLeanToWallSurfaceHit, } from './layout' +import { isLeanToHostOnLevel } from './placement-scope' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' import LeanToExtensionPreview from './preview' import { @@ -103,6 +104,10 @@ const LeanToExtensionTool = () => { const updateConicalSegmentTarget = (event: RoofSegmentEvent) => { const nodes = sceneApi.nodes() as Record + if (!isLeanToHostOnLevel(event.node, nodes, activeLevelId)) { + setPreview(null) + return null + } const node = resolveConicalLeanToSurfaceHit(event.node, event.localPosition, event.normal) if (!node) { setPreview(null) @@ -114,8 +119,14 @@ const LeanToExtensionTool = () => { } const updateConicalRoofTarget = (event: RoofEvent) => { - if (event.object.name !== 'merged-roof') return null const nodes = sceneApi.nodes() as Record + if ( + !isLeanToHostOnLevel(event.node, nodes, activeLevelId) || + event.object.name !== 'merged-roof' + ) { + 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 @@ -157,6 +168,11 @@ const LeanToExtensionTool = () => { } const updateTarget = (event: WallEvent) => { + const nodes = sceneApi.nodes() as Record + if (!isLeanToHostOnLevel(event.node, nodes, activeLevelId)) { + setPreview(null) + return null + } const hit = resolveLeanToWallSurfaceHit(event.node, event.localPosition, event.normal) if (!hit) { setPreview(null) @@ -167,7 +183,6 @@ const LeanToExtensionTool = () => { setPreview(null) return null } - const nodes = sceneApi.nodes() as Record const attachment = resolveLeanToRoofAttachment(wallPlacement, event.node, nodes) const autoSpannedNode = attachment ? applyLeanToRoofAttachment(wallPlacement, attachment) diff --git a/packages/nodes/src/roof-segment/definition.test.ts b/packages/nodes/src/roof-segment/definition.test.ts index 55de482c3c..d25bd52e23 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,20 +65,25 @@ function pitchHandle(): LinearResizeHandle { } describe('roof-segment resize handles', () => { - test('keeps conical diameter circular and omits rotation', () => { + 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 widthHandle = conicalHandles.find( - (handle): handle is LinearResizeHandle => - handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === 'min', + const radiusHandles = conicalHandles.filter( + (handle): handle is RadialResizeHandle => handle.kind === 'radial-resize', ) - const depthHandle = conicalHandles.find( - (handle): handle is LinearResizeHandle => - handle.kind === 'linear-resize' && handle.axis === 'z' && handle.anchor === 'min', + const sideHandles = conicalHandles.filter( + (handle) => handle.kind === 'linear-resize' && (handle.axis === 'x' || handle.axis === 'z'), ) + const radiusHandle = radiusHandles[0] - expect(widthHandle?.apply(node, 8, undefined as never)).toMatchObject({ width: 8, depth: 8 }) - expect(depthHandle?.apply(node, 9, undefined as never)).toMatchObject({ width: 9, depth: 9 }) + 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) }) diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 930b1ec07a..7cfe8d1734 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -174,6 +174,24 @@ function roofSegmentDepthHandle(side: 'front' | 'back'): 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 @@ -282,11 +300,17 @@ const roofSegmentHandles: HandleDescriptor[] = [ roofSegmentRotateHandle(), ] +const conicalRoofSegmentHandles: HandleDescriptor[] = [ + conicalRoofSegmentRadiusHandle(), + roofSegmentWallHeightHandle(), + roofSegmentPitchHandle(), +] + function resolveRoofSegmentHandles( node: RoofSegmentNodeType, ): HandleDescriptor[] { if (isManagedLeanToRoofSegment(node)) return [] - return node.roofType === 'conical' ? roofSegmentHandles.slice(0, -1) : roofSegmentHandles + return node.roofType === 'conical' ? conicalRoofSegmentHandles : roofSegmentHandles } /** 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 f7ddae536e..3d282a67ef 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -14,7 +14,7 @@ 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 diff --git a/packages/nodes/src/roof-segment/floorplan.test.ts b/packages/nodes/src/roof-segment/floorplan.test.ts index 33e73f45d5..7187d96102 100644 --- a/packages/nodes/src/roof-segment/floorplan.test.ts +++ b/packages/nodes/src/roof-segment/floorplan.test.ts @@ -56,6 +56,9 @@ describe('getRoofSegmentPlanLinework', () => { 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: [], diff --git a/packages/nodes/src/roof-segment/floorplan.ts b/packages/nodes/src/roof-segment/floorplan.ts index ccb0e714b4..21fd393062 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -139,9 +139,8 @@ export function buildRoofSegmentFloorplan( // The shape math lives in `getRoofSegmentPlanLinework` (exported for the // roof builder to consume). - // Selection chrome — orange move-handle dot at the centre, four - // perpendicular side resize-arrows (width on X, depth on Z), and a - // rotate-arrow at the +X/+Z corner. Sister to the 3D handles in + // Selection chrome — orange move-handle dot at the centre, footprint + // resize arrows, and a rotate-arrow at the +X/+Z corner. Sister to the 3D handles in // `definition.ts`. Resize/rotate route through the matching // `floorplanAffordances`; the dot drives body-move via // `def.floorplanMoveTarget`. @@ -162,27 +161,39 @@ 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 diff --git a/packages/viewer/src/systems/roof/roof-system.test.ts b/packages/viewer/src/systems/roof/roof-system.test.ts index 27e113f9fc..6dcde3f2bc 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' @@ -118,6 +118,43 @@ describe('roof system shed geometry', () => { 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 diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 41ea5c4ad0..8fa6c6e0d1 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -22,6 +22,7 @@ import { roofOverlapEntryOwns, roofPlanBoundsOverlap, sceneRegistry, + unionPolygons, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -636,7 +637,7 @@ function updateMergedRoofGeometry( totalDeckSlab = brushes.deckSlab } - if (child.roofType === 'shed' && isManagedLeanToRoofSegment(child)) { + if (!shouldIncludeRoofSegmentWallShell(child, roofNode)) { brushes.wallBrush.geometry.dispose() brushes.innerBrush.geometry.dispose() } else { @@ -1043,6 +1044,16 @@ function isManagedLeanToRoofSegment(node: Pick): bo return record.managedByLeanTo !== undefined && record.leanToRole === 'roof-segment' } +function shouldIncludeRoofSegmentWallShell(node: RoofSegmentNode, parentRoof?: RoofNode): boolean { + if (node.roofType !== 'shed') return true + if (isManagedLeanToRoofSegment(node)) return false + + // 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 ( @@ -1648,13 +1659,10 @@ export function generateRoofSegmentGeometry( node: RoofSegmentNode, nodes?: Record, ): THREE.BufferGeometry { - const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined - const parentRoofPosition = - parentRoof && 'position' in parentRoof ? (parentRoof.position as number[]) : undefined - const parentRoofRotation = - parentRoof && 'rotation' in parentRoof - ? ((parentRoof as { rotation?: number }).rotation ?? 0) - : 0 + const parentNode = node.parentId ? nodes?.[node.parentId] : undefined + const parentRoof = parentNode?.type === 'roof' ? parentNode : undefined + const parentRoofPosition = parentRoof?.position + const parentRoofRotation = parentRoof?.rotation ?? 0 const segmentWorldMatrix = composeSegmentWorldMatrix( parentRoofPosition, parentRoofRotation, @@ -1691,7 +1699,7 @@ export function generateRoofSegmentGeometry( prepareBrushForCSG(shinDeck) let combined = shinDeck let hollowWall: Brush | null = null - if (!(node.roofType === 'shed' && isManagedLeanToRoofSegment(node))) { + if (shouldIncludeRoofSegmentWallShell(node, parentRoof)) { hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION) prepareBrushForCSG(hollowWall) combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) @@ -2127,6 +2135,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, @@ -2150,6 +2185,43 @@ function facetBandedRoofPieces( return faceted } +function facetBandedRoofBoundary( + polygon: RoofPlanPolygon, + width: number, +): [RoofPlanPolygon[number], RoofPlanPolygon[number]][] { + const facetCount = Math.max(4, Math.min(32, Math.ceil(width / 0.4))) + const halfWidth = width / 2 + const facetWidth = width / facetCount + const boundaries = Array.from( + { length: facetCount - 1 }, + (_, index) => -halfWidth + (index + 1) * facetWidth, + ) + const segments: [RoofPlanPolygon[number], RoofPlanPolygon[number]][] = [] + for (let index = 0; index < polygon.length; index++) { + const start = polygon[index]! + const end = polygon[(index + 1) % polygon.length]! + const deltaX = end[0] - start[0] + const splits = boundaries + .flatMap((boundaryX) => { + if (Math.abs(deltaX) <= 1e-8) return [] + const ratio = (boundaryX - start[0]) / deltaX + return ratio > 1e-8 && ratio < 1 - 1e-8 ? [ratio] : [] + }) + .sort((left, right) => left - right) + const points = [0, ...splits, 1].map( + (ratio) => + [ + start[0] + (end[0] - start[0]) * ratio, + start[1] + (end[1] - start[1]) * ratio, + ] as RoofPlanPolygon[number], + ) + for (let pointIndex = 0; pointIndex + 1 < points.length; pointIndex++) { + segments.push([points[pointIndex]!, points[pointIndex + 1]!]) + } + } + return segments +} + function buildCustomShedGeometry(node: RoofSegmentNode): THREE.BufferGeometry | null { if (node.roofType !== 'shed') return null const pieces = readShedFootprintPieces(node) @@ -2165,27 +2237,45 @@ function buildCustomShedGeometry(node: RoofSegmentNode): THREE.BufferGeometry | const geometries: THREE.BufferGeometry[] = [] for (const polygon of renderPieces) { - const signedArea = polygon.reduce((area, point, index) => { - const next = polygon[(index + 1) % polygon.length]! + const sanitized = sanitizeRoofPlanPolygon(polygon) + const signedArea = sanitized.reduce((area, point, index) => { + const next = sanitized[(index + 1) % sanitized.length]! return area + point[0] * next[1] - next[0] * point[1] }, 0) if (Math.abs(signedArea) <= 1e-9) continue - const outline = signedArea > 0 ? polygon : [...polygon].reverse() + const outline = signedArea > 0 ? sanitized : [...sanitized].reverse() const bottom = outline.map( ([x, z]) => new THREE.Vector3(x, getRoofSegmentSurfaceY(node, x, z), z), ) - const top = [...bottom] - .reverse() - .map((point) => new THREE.Vector3(point.x, point.y + verticalThickness, point.z)) - const faces: THREE.Vector3[][] = [bottom, top] - for (let index = 0; index < bottom.length; index++) { - const next = (index + 1) % bottom.length - faces.push([ - bottom[next]!.clone(), - bottom[index]!.clone(), - new THREE.Vector3(bottom[index]!.x, bottom[index]!.y + verticalThickness, bottom[index]!.z), - new THREE.Vector3(bottom[next]!.x, bottom[next]!.y + verticalThickness, bottom[next]!.z), - ]) + const triangles = THREE.ShapeUtils.triangulateShape( + outline.map(([x, z]) => new THREE.Vector2(x, z)), + [], + ) + const faces: THREE.Vector3[][] = triangles.flatMap((triangle) => { + const bottomFace = triangle.map((index) => bottom[index]!.clone()) + const normalY = new THREE.Vector3() + .subVectors(bottomFace[1]!, bottomFace[0]!) + .cross(new THREE.Vector3().subVectors(bottomFace[2]!, bottomFace[0]!)).y + if (normalY > 0) bottomFace.reverse() + const topFace = [...bottomFace] + .reverse() + .map((point) => new THREE.Vector3(point.x, point.y + verticalThickness, point.z)) + return [bottomFace, topFace] + }) + if (!banded) { + for (let index = 0; index < bottom.length; index++) { + const next = (index + 1) % bottom.length + faces.push([ + bottom[next]!.clone(), + bottom[index]!.clone(), + new THREE.Vector3( + bottom[index]!.x, + bottom[index]!.y + verticalThickness, + bottom[index]!.z, + ), + new THREE.Vector3(bottom[next]!.x, bottom[next]!.y + verticalThickness, bottom[next]!.z), + ]) + } } geometries.push( createGeometryFromFaces(faces, (normal) => @@ -2194,6 +2284,30 @@ function buildCustomShedGeometry(node: RoofSegmentNode): THREE.BufferGeometry | ) } + if (banded) { + const boundaryFaces = unionPolygons(pieces.map((piece) => [...piece])).flatMap((polygon) => + facetBandedRoofBoundary(sanitizeRoofPlanPolygon(polygon), node.width).map(([start, end]) => { + const startBottom = new THREE.Vector3( + start[0], + getRoofSegmentSurfaceY(node, start[0], start[1]), + start[1], + ) + const endBottom = new THREE.Vector3( + end[0], + getRoofSegmentSurfaceY(node, end[0], end[1]), + end[1], + ) + return [ + endBottom, + startBottom, + new THREE.Vector3(startBottom.x, startBottom.y + verticalThickness, startBottom.z), + new THREE.Vector3(endBottom.x, endBottom.y + verticalThickness, endBottom.z), + ] + }), + ) + geometries.push(createGeometryFromFaces(boundaryFaces, ROOF_EDGE_MATERIAL_INDEX)) + } + if (geometries.length === 0) return null const merged = mergeGeometriesPreservingGroups(geometries) for (const geometry of geometries) geometry.dispose() From 6ae92c1e2eb4523b57eb5c73c0c0f7319e4331c8 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 25 Aug 2026 12:42:28 +0530 Subject: [PATCH 6/9] feat: improve roof openings and lean-to canopies --- packages/core/src/registry/handles.ts | 11 + packages/core/src/schema/index.ts | 6 + packages/core/src/schema/nodes/dormer.ts | 128 ++++++- .../src/schema/nodes/lean-to-extension.ts | 22 +- .../core/src/schema/nodes/roof-segment.ts | 3 + .../store/use-scene-window-migration.test.ts | 51 ++- packages/core/src/store/use-scene.ts | 14 +- .../components/editor/node-arrow-handles.tsx | 36 +- .../tools/item/placement-math.test.ts | 12 +- .../tools/item/use-placement-coordinator.tsx | 60 ++-- .../tools/registry-tool-context.tsx | 1 + .../src/components/tools/tool-manager.tsx | 9 +- .../src/components/ui/action-menu/index.tsx | 12 +- .../components/ui/panels/panel-manager.tsx | 4 + .../editor/src/hooks/use-keyboard.test.ts | 18 +- packages/editor/src/hooks/use-keyboard.ts | 33 +- .../src/lib/direct-manipulation.test.ts | 73 ---- .../editor/src/lib/direct-manipulation.ts | 26 -- .../lib/interaction/overlay-policy.test.ts | 9 +- .../src/lib/interaction/overlay-policy.ts | 4 + .../editor/src/lib/selection-routing.test.ts | 69 +++- packages/editor/src/lib/selection-routing.ts | 19 ++ packages/nodes/src/column/parametrics.test.ts | 31 ++ packages/nodes/src/column/parametrics.ts | 2 + .../src/dormer/__tests__/geometry.test.ts | 98 +++++- packages/nodes/src/dormer/csg-geometry.ts | 229 +------------ packages/nodes/src/dormer/definition.ts | 201 +---------- packages/nodes/src/dormer/floorplan.ts | 11 - packages/nodes/src/dormer/geometry.ts | 12 - packages/nodes/src/dormer/index.ts | 6 +- .../nodes/src/dormer/panel-window-section.tsx | 315 ------------------ packages/nodes/src/dormer/panel.tsx | 13 +- packages/nodes/src/dormer/parametrics.ts | 78 ----- packages/nodes/src/dormer/renderer.tsx | 34 -- packages/nodes/src/dormer/tool.tsx | 9 +- packages/nodes/src/dormer/window-assembly.tsx | 210 ------------ packages/nodes/src/dormer/window-frame.ts | 160 --------- .../src/lean-to-extension/assembly.test.ts | 40 +++ .../nodes/src/lean-to-extension/assembly.ts | 55 ++- .../src/lean-to-extension/definition.test.ts | 38 +++ .../nodes/src/lean-to-extension/definition.ts | 61 +++- .../floorplan-affordances.ts | 130 ++++++-- .../lean-to-extension/floorplan-move.test.ts | 85 +++++ .../src/lean-to-extension/floorplan-move.ts | 45 ++- .../src/lean-to-extension/floorplan-tool.tsx | 129 ++++--- .../src/lean-to-extension/floorplan.test.ts | 35 ++ .../nodes/src/lean-to-extension/floorplan.ts | 99 +++++- packages/nodes/src/lean-to-extension/index.ts | 7 + .../nodes/src/lean-to-extension/move-tool.tsx | 300 +++++++++++------ .../src/lean-to-extension/parametrics.ts | 21 +- .../src/lean-to-extension/placement.test.ts | 260 +++++++++++++++ .../nodes/src/lean-to-extension/placement.ts | 282 ++++++++++++++++ .../lean-to-extension/post-omissions.test.ts | 51 +++ .../src/lean-to-extension/post-omissions.ts | 58 ++++ .../preview-geometry.test.ts | 80 +++++ .../src/lean-to-extension/preview-geometry.ts | 42 +++ .../nodes/src/lean-to-extension/preview.tsx | 55 +-- .../nodes/src/lean-to-extension/renderer.tsx | 5 +- .../src/lean-to-extension/system.test.ts | 182 +++++++++- .../nodes/src/lean-to-extension/system.tsx | 37 +- packages/nodes/src/lean-to-extension/tool.tsx | 230 +++++++++++-- .../nodes/src/roof-segment/definition.test.ts | 22 +- packages/nodes/src/roof-segment/definition.ts | 13 +- packages/nodes/src/roof/definition.ts | 2 +- .../dormer-wall-opening-placement.test.ts | 151 ++++++++- .../shared/dormer-wall-opening-placement.ts | 75 +++-- packages/nodes/src/window/definition.test.ts | 188 +++++++++++ packages/nodes/src/window/definition.ts | 98 ++++-- packages/nodes/src/window/move-tool.tsx | 63 +++- packages/nodes/src/window/tool.tsx | 52 +-- packages/viewer/src/lib/materials.ts | 4 +- .../src/systems/roof/roof-system.test.ts | 3 +- .../viewer/src/systems/roof/roof-system.tsx | 12 +- 73 files changed, 3194 insertions(+), 1845 deletions(-) create mode 100644 packages/nodes/src/column/parametrics.test.ts delete mode 100644 packages/nodes/src/dormer/panel-window-section.tsx delete mode 100644 packages/nodes/src/dormer/window-assembly.tsx delete mode 100644 packages/nodes/src/dormer/window-frame.ts create mode 100644 packages/nodes/src/lean-to-extension/placement.test.ts create mode 100644 packages/nodes/src/lean-to-extension/placement.ts create mode 100644 packages/nodes/src/lean-to-extension/post-omissions.test.ts create mode 100644 packages/nodes/src/lean-to-extension/post-omissions.ts create mode 100644 packages/nodes/src/lean-to-extension/preview-geometry.test.ts create mode 100644 packages/nodes/src/lean-to-extension/preview-geometry.ts create mode 100644 packages/nodes/src/window/definition.test.ts diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 7297c857ed..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' @@ -205,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 @@ -267,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 } @@ -294,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 /** @@ -341,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[]` @@ -389,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 } @@ -436,6 +445,7 @@ export type TranslateHandle = { */ snapExtents?: (node: N, sceneApi: SceneApi) => readonly [number, number] | null portal?: HandlePortal + portalTarget?: HandlePortalTarget } /** @@ -455,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 c51c2bb694..a617dac4ab 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -107,7 +107,13 @@ export { type DormerSurfaceMaterialRole, type DormerSurfaceMaterialSpec, DormerWallFace, + dormerPointToWallFace, + dormerWallFacePointToDormer, + getDormerDefaultWindowFace, + getDormerExposedFaces, getDormerWallFaceFrame, + getDormerWallHorizontalBoundsAtHeight, + getDormerWallOpeningVerticalBounds, getDormerWallVerticalBounds, getEffectiveDormerSurfaceMaterial, } from './nodes/dormer' diff --git a/packages/core/src/schema/nodes/dormer.ts b/packages/core/src/schema/nodes/dormer.ts index a961cbc68b..84bbcbcd29 100644 --- a/packages/core/src/schema/nodes/dormer.ts +++ b/packages/core/src/schema/nodes/dormer.ts @@ -2,7 +2,7 @@ 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' @@ -136,6 +136,35 @@ export function getDormerWallFaceFrame( } } +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, ) { @@ -145,6 +174,100 @@ export function getDormerWallVerticalBounds( } } +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, @@ -168,6 +291,7 @@ export function createDormerDefaultWindow( | '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) @@ -175,7 +299,7 @@ export function createDormerDefaultWindow( id, parentId: dormer.id, dormerId: dormer.id, - dormerFace: 'front', + dormerFace: face, position: [dormer.windowOffsetX, -skirt / 2 + dormer.windowOffsetY, 0], rotation: [0, 0, 0], side: 'front', diff --git a/packages/core/src/schema/nodes/lean-to-extension.ts b/packages/core/src/schema/nodes/lean-to-extension.ts index b5f6f0dd02..eab68a8934 100644 --- a/packages/core/src/schema/nodes/lean-to-extension.ts +++ b/packages/core/src/schema/nodes/lean-to-extension.ts @@ -3,9 +3,10 @@ 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 LeanToHostKind = z.enum(['wall', 'conical-roof']) +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', @@ -18,6 +19,11 @@ 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 @@ -31,6 +37,9 @@ export const LeanToExtensionNode = BaseNode.extend({ children: z.array(z.union([ColumnNode.shape.id, RoofNode.shape.id])).default([]), 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), @@ -104,15 +113,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` - Hosted lean-to roof extension. - The high edge attaches to a wall or wraps around a conical roof's cylindrical base, 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 lean-to 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. The mono-pitch roof falls along + local +Z to a beam supported by managed column children. Its roof is a standard shed roof + segment with standard gutter and downspout children, not a standalone enclosed shed roof. `, ) diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index de99e545fe..f8f8783c72 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -146,6 +146,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. 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 d424459fd9..1fae38a541 100644 --- a/packages/core/src/store/use-scene-window-migration.test.ts +++ b/packages/core/src/store/use-scene-window-migration.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'bun:test' -import type { AnyNode } from '../schema' +import { type AnyNode, getRoofSegmentSurfaceY, type RoofSegmentNode } from '../schema' import useScene from './use-scene' describe('scene window migrations', () => { @@ -127,4 +127,53 @@ describe('scene window migrations', () => { expect(window.columnRatios).toEqual([1, 1]) expect(window.rowRatios).toEqual([1, 1, 1]) }) + + test('puts the promoted window on the exposed dormer face', () => { + const segment = { + object: 'node', + id: 'rseg_test', + type: 'roof-segment', + parentId: null, + visible: true, + metadata: {}, + children: ['dormer_test'], + position: [0, 0, 0], + rotation: 0, + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 40, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + } as RoofSegmentNode + const dormerY = getRoofSegmentSurfaceY(segment, 0, -1.5) + + useScene.getState().setScene( + { + rseg_test: segment, + dormer_test: { + object: 'node', + id: 'dormer_test', + type: 'dormer', + parentId: 'rseg_test', + visible: true, + metadata: {}, + roofSegmentId: 'rseg_test', + position: [0, dormerY, -1.5], + rotation: 0, + }, + } as unknown as Record, + ['rseg_test'] as never, + ) + + const dormer = useScene.getState().nodes.dormer_test as Extract + const window = useScene.getState().nodes[dormer.children[0]] as Extract< + AnyNode, + { type: 'window' } + > + expect(window.dormerFace).toBe('back') + }) }) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 98923f21b9..7be3cc3a8d 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -9,7 +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 } from '../schema/nodes/dormer' +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 { @@ -815,7 +819,13 @@ function migrateNodes(nodes: Record): { windowId = `${baseWindowId}_${suffix}` suffix += 1 } - const window = createDormerDefaultWindow(dormer, windowId) + 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] } } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 3f688fe644..3981429a0d 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -43,10 +43,6 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help' -import { - resolveHandlePortalTargetId, - shouldShowMoveCrossHandle, -} from '../../lib/direct-manipulation' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' @@ -239,13 +235,15 @@ export function NodeArrowHandles() { typeof def.handles === 'function' ? def.handles(node as never, descriptorSceneApi) : (def.handles as HandleDescriptor[]) - const showMoveCross = shouldShowMoveCrossHandle(node) - return all.filter( - (d) => - d.kind !== 'translate' && - !('shape' in d && d.shape === 'move-cross' && !showMoveCross) && - (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 = @@ -299,10 +297,20 @@ function NodeArrowHandlesForNode({ ? '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 = useScene((state) => - resolveHandlePortalTargetId(node, state.nodes, portalMode), - ) + 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 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 6130a296c4..5c0baf5f9e 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/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/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index eb4ce63787..01b3660b1d 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -24,6 +24,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import { useIsMobile } from '../../../hooks/use-mobile' +import { shouldShowEditingControls } from '../../../lib/interaction/overlay-policy' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from '../../editor/group-actions' @@ -225,6 +226,7 @@ export function PanelManager({ const selectedZoneId = useViewer((s) => 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/hooks/use-keyboard.test.ts b/packages/editor/src/hooks/use-keyboard.test.ts index 1902f88df0..b4deaaf0e8 100644 --- a/packages/editor/src/hooks/use-keyboard.test.ts +++ b/packages/editor/src/hooks/use-keyboard.test.ts @@ -7,8 +7,9 @@ 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 { runHistoryShortcut } from './use-keyboard' +import { isToolOwnedRotation, runHistoryShortcut } from './use-keyboard' type RafFn = (callback: (time: number) => void) => number ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( @@ -38,9 +39,24 @@ 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) + }) +}) + describe('history shortcuts during block editing', () => { test('undoes and redoes mesh changes without leaving component selection mode', () => { useInteractionScope.getState().begin(meshEditScope(NODE_ID)) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index a16bbbfb67..2e8fa358da 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -171,6 +171,20 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => { return true } +export const isToolOwnedRotation = () => { + const editor = useEditor.getState() + const moving = getMovingNode() + if (moving?.type === 'door' || moving?.type === 'window' || moving?.type === 'item') return true + return ( + editor.mode === 'build' && + (editor.tool === 'door' || + editor.tool === 'window' || + editor.tool === 'roof' || + editor.tool === 'item' || + editor.tool === 'lean-to-extension') + ) +} + export const useKeyboard = ({ isVersionPreviewMode = false, disabled = false, @@ -184,17 +198,8 @@ 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') - ) - } - + // 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, @@ -490,9 +495,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/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 48c669d243..95b32bb2b3 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -11,9 +11,7 @@ import { canDirectMoveNode, resolveDirectManipulationNode, resolveDirectRotationDragDelta, - resolveHandlePortalTargetId, resolveMoveActionNode, - shouldShowMoveCrossHandle, snapDirectRotationDelta, } from './direct-manipulation' @@ -118,77 +116,6 @@ describe('canDirectMoveNode', () => { }) }) -describe('shouldShowMoveCrossHandle', () => { - test('shows the drag grip only for dormer-hosted windows', () => { - expect( - shouldShowMoveCrossHandle({ - id: 'window_dormer', - type: 'window', - dormerId: 'dormer_1', - } as unknown as AnyNode), - ).toBe(true) - expect( - shouldShowMoveCrossHandle({ - id: 'window_wall', - type: 'window', - wallId: 'wall_1', - } as unknown as AnyNode), - ).toBe(false) - expect(shouldShowMoveCrossHandle({ id: 'door_1', type: 'door' } as unknown as AnyNode)).toBe( - false, - ) - }) -}) - -describe('resolveHandlePortalTargetId', () => { - test('portals dormer-window handles outside the hidden roof-segment container', () => { - const roof = { id: 'roof_1', type: 'roof' } as unknown as AnyNode - const segment = { - id: 'roof_segment_1', - type: 'roof-segment', - parentId: roof.id, - } as unknown as AnyNode - const dormer = { - id: 'dormer_1', - type: 'dormer', - parentId: segment.id, - } as unknown as AnyNode - const window = { - id: 'window_1', - type: 'window', - parentId: dormer.id, - dormerId: dormer.id, - } as unknown as AnyNode - const nodes = { - [roof.id]: roof, - [segment.id]: segment, - [dormer.id]: dormer, - [window.id]: window, - } - - expect(resolveHandlePortalTargetId(window, nodes, 'grandparent')).toBe(roof.id) - }) - - test('keeps the regular grandparent portal for wall-hosted windows', () => { - const level = { id: 'level_1', type: 'level' } as unknown as AnyNode - const wall = { - id: 'wall_1', - type: 'wall', - parentId: level.id, - } as unknown as AnyNode - const window = { - id: 'window_1', - type: 'window', - parentId: wall.id, - wallId: wall.id, - } as unknown as AnyNode - - expect( - resolveHandlePortalTargetId(window, { [level.id]: level, [wall.id]: wall }, 'grandparent'), - ).toBe(level.id) - }) -}) - describe('resolveDirectManipulationNode', () => { test('routes proxied members to their assembly for direct transforms', () => { const group = { diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index 0a5f57f684..b08c739a1b 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -64,32 +64,6 @@ export function canDirectMoveNode(node: AnyNode): boolean { return isMovable(node) } -export function shouldShowMoveCrossHandle(node: AnyNode): boolean { - return node.type === 'window' && Boolean(node.dormerId) -} - -export function resolveHandlePortalTargetId( - node: AnyNode, - nodes: Readonly>, - portal: 'parent' | 'grandparent', -): string | null { - const parentId = node.parentId - if (!parentId || portal === 'parent') return parentId ?? null - - const grandparent = nodes[parentId]?.parentId - if (!grandparent) return null - - // Unpainted roof segments render inside a hidden group. A dormer window's - // normal grandparent is that segment, so handles portalled there inherit - // visibility=false. The roof is the nearest visible portal container. - if (node.type === 'window' && node.dormerId === parentId) { - const host = nodes[grandparent] - if (host?.type === 'roof-segment' && host.parentId) return host.parentId - } - - return grandparent -} - export function resolveDirectManipulationNode( node: AnyNode, nodes: Readonly>, 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/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/nodes/src/column/parametrics.test.ts b/packages/nodes/src/column/parametrics.test.ts new file mode 100644 index 0000000000..ea677d553c --- /dev/null +++ b/packages/nodes/src/column/parametrics.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { ColumnNode, LeanToExtensionNode } from '@pascal-app/core' +import { columnParametrics } from './parametrics' + +describe('column deletion', () => { + test('records a deleted managed lean-to pillar on its canopy', () => { + const canopy = LeanToExtensionNode.parse({ id: 'leanto_delete_managed_post' }) + const pillar = ColumnNode.parse({ + id: 'column_delete_managed_post', + parentId: canopy.id, + metadata: { + managedByLeanTo: canopy.id, + leanToRole: 'post', + leanToPostIndex: 1, + leanToPostSide: 'high', + }, + }) + + const updates = columnParametrics.onDelete?.(pillar, { + [canopy.id]: canopy, + [pillar.id]: pillar, + }) + + expect(updates).toEqual([ + { + id: canopy.id, + data: { omittedPostSlots: [{ side: 'high', index: 1, layoutCount: 3 }] }, + }, + ]) + }) +}) diff --git a/packages/nodes/src/column/parametrics.ts b/packages/nodes/src/column/parametrics.ts index 340c58f6f9..5bbe257a94 100644 --- a/packages/nodes/src/column/parametrics.ts +++ b/packages/nodes/src/column/parametrics.ts @@ -1,4 +1,5 @@ import type { ParametricDescriptor } from '@pascal-app/core' +import { leanToPostOmissionPatchesOnDelete } from '../lean-to-extension/post-omissions' import type { ColumnNode } from './schema' /** @@ -10,6 +11,7 @@ import type { ColumnNode } from './schema' * full legacy panel — Stage E will replace it via `customPanel`. */ export const columnParametrics: ParametricDescriptor = { + onDelete: leanToPostOmissionPatchesOnDelete, groups: [ { label: 'Dimensions', diff --git a/packages/nodes/src/dormer/__tests__/geometry.test.ts b/packages/nodes/src/dormer/__tests__/geometry.test.ts index 357c98288a..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 { buildDormerRoofCut, 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)', () => { @@ -138,16 +142,70 @@ describe('buildDormerRoofCut', () => { }) }) -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('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('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) + + 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() }) }) @@ -220,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/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index a4bc7d9b66..b2a33aaa9c 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -1,8 +1,8 @@ import { type DormerNode, + dormerWallFacePointToDormer, getDormerWallFaceFrame, getPitchFromActiveRoofHeight, - getRoofSegmentSurfaceY, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, type WindowNode, @@ -51,103 +51,6 @@ 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), - ) - 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) -} - function createHostedWindowCutGeometry(window: WindowNode): THREE.BufferGeometry { const depth = 0.4 return buildOpeningCutoutGeometry( @@ -163,86 +66,6 @@ function createHostedWindowCutGeometry(window: WindowNode): THREE.BufferGeometry ) } -// 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 } -} - /** * Build the trimmed dormer geometry hosted on a roof segment. The * dormer's own walls+roof are generated via `getRoofSegmentBrushes` @@ -312,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) @@ -407,24 +233,14 @@ export function generateDormerGeometry( dormerSolid = trimmed } - // Cut hosted window openings in dormer-local face coordinates. Scenes - // loaded through the core migration always have a child window; the - // legacy branch keeps direct geometry callers and un-migrated previews - // visually compatible. + // 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( - frame.origin[0] + - window.position[0] * Math.cos(frame.yaw) - - window.position[2] * Math.sin(frame.yaw), - window.position[1], - frame.origin[2] + - window.position[0] * Math.sin(frame.yaw) + - window.position[2] * Math.cos(frame.yaw), - ) + cutGeo.translate(center[0], center[1], center[2]) if (!cutGeo.getIndex()) { const posCount = cutGeo.getAttribute('position').count const idx = new Uint32Array(posCount) @@ -444,36 +260,7 @@ export function generateDormerGeometry( dormerSolid = result } - if (hostedWindows.length > 0) { - for (const window of hostedWindows) cutWindow(window) - } else { - const exposed = getDormerExposedFaces(dormer, hostSegment) - const skirtWin = getDormerSkirtWindowDims(dormer) - const gableHalfZ = dormer.depth / 2 - const cutLegacyFace = (zSign: number) => { - const cutGeo = createDormerWindowCutGeometry(dormer, skirtWin.width, skirtWin.height, 0.4) - cutGeo.translate(skirtWin.offsetX, skirtWin.centerY, zSign * gableHalfZ) - if (!cutGeo.getIndex()) { - const posCount = cutGeo.getAttribute('position').count - const idx = new Uint32Array(posCount) - for (let i = 0; i < posCount; i++) idx[i] = i - cutGeo.setIndex(new THREE.BufferAttribute(idx, 1)) - } - const idxCount = cutGeo.getIndex()!.count - cutGeo.clearGroups() - cutGeo.addGroup(0, idxCount, 0) - computeGeometryBoundsTree(cutGeo) - const brush = new Brush(cutGeo, roofCsgDummyMats[0]) - prepareBrushForCSG(brush) - const result = csgEvaluator.evaluate(dormerSolid!, brush, SUBTRACTION) as Brush - prepareBrushForCSG(result) - dormerSolid!.geometry.dispose() - brush.geometry.dispose() - dormerSolid = result - } - if (exposed.front) cutLegacyFace(+1) - if (exposed.back) cutLegacyFace(-1) - } + for (const window of hostedWindows) cutWindow(window) resultGeo = csgGeometry(dormerSolid) const resultMaterials = csgMaterials(dormerSolid) diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 21e6b3384e..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,8 +248,8 @@ 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(), ] @@ -436,19 +259,12 @@ const dormerHandles: HandleDescriptor[] = [ * 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: 3, + schemaVersion: 4, schema: DormerNode, category: 'structure', surfaceRole: 'roof', @@ -522,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 e8c886e753..de6b8d9c9a 100644 --- a/packages/nodes/src/dormer/floorplan.ts +++ b/packages/nodes/src/dormer/floorplan.ts @@ -197,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 f276a866c4..40ca77bb05 100644 --- a/packages/nodes/src/dormer/geometry.ts +++ b/packages/nodes/src/dormer/geometry.ts @@ -94,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/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.tsx b/packages/nodes/src/dormer/panel.tsx index f8d588cb1b..11c3fad1e3 100644 --- a/packages/nodes/src/dormer/panel.tsx +++ b/packages/nodes/src/dormer/panel.tsx @@ -166,7 +166,9 @@ export default function DormerPanel() { const handleAddWindow = useCallback(() => { if (!node) return - const frontWindows = hostedWindows.filter((window) => (window.dormerFace ?? 'front') === 'front') + const frontWindows = hostedWindows.filter( + (window) => (window.dormerFace ?? 'front') === 'front', + ) const template = frontWindows[0] ?? hostedWindows[0] const id = generateId('window') const defaultWindow = createDormerDefaultWindow(node, id) @@ -234,17 +236,16 @@ 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 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] ?? 0, 0], - width: templateWindow?.width ?? node.windowWidth, + position: [0, templateWindow?.position[1] ?? defaultWindow.position[1], 0], + width: templateWindow?.width ?? defaultWindow.width, }, ]) !== null diff --git a/packages/nodes/src/dormer/parametrics.ts b/packages/nodes/src/dormer/parametrics.ts index a8733a5f53..f9fe43aa18 100644 --- a/packages/nodes/src/dormer/parametrics.ts +++ b/packages/nodes/src/dormer/parametrics.ts @@ -1,5 +1,4 @@ import type { ParametricDescriptor } from '@pascal-app/core' -import { dormerSupportsArch } from './geometry' import type { DormerNode } from './schema' export const dormerParametrics: ParametricDescriptor = { @@ -39,82 +38,5 @@ export const dormerParametrics: ParametricDescriptor = { label: 'Hung wall', fields: [{ key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 }], }, - { - label: 'Window opening', - fields: [ - { key: 'windowWidth', kind: 'number', unit: 'm', min: 0.2, max: 3, step: 0.05 }, - { key: 'windowHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 }, - { key: 'windowOffsetX', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.05 }, - { key: 'windowOffsetY', kind: 'number', unit: 'm', min: 0, max: 2, step: 0.05 }, - ], - }, - { - label: 'Window grid', - fields: [ - { key: 'windowColumns', kind: 'number', min: 1, max: 8, step: 1 }, - { key: 'windowRows', kind: 'number', min: 1, max: 8, step: 1 }, - ], - }, - { - label: 'Window frame', - fields: [ - { - key: 'windowFrameThickness', - kind: 'number', - unit: 'm', - min: 0.01, - max: 0.15, - step: 0.005, - }, - { key: 'windowFrameDepth', kind: 'number', unit: 'm', min: 0.02, max: 0.15, step: 0.005 }, - { - key: 'windowDividerThickness', - kind: 'number', - unit: 'm', - min: 0, - max: 0.06, - step: 0.002, - }, - { - key: 'windowShape', - kind: 'enum', - options: ['rectangle', 'rounded', 'arch'], - display: 'segmented', - }, - { - key: 'windowArchHeight', - kind: 'number', - unit: 'm', - min: 0.1, - max: 1, - step: 0.05, - visibleIf: dormerSupportsArch, - }, - ], - }, - { - label: 'Sill', - fields: [ - { key: 'windowSill', kind: 'boolean' }, - { - key: 'windowSillDepth', - kind: 'number', - unit: 'm', - min: 0.02, - max: 0.3, - step: 0.01, - visibleIf: (n) => n.windowSill === true, - }, - { - key: 'windowSillThickness', - kind: 'number', - unit: 'm', - min: 0.01, - max: 0.1, - step: 0.005, - visibleIf: (n) => n.windowSill === true, - }, - ], - }, ], } diff --git a/packages/nodes/src/dormer/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index c6733db467..f021857d46 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -31,7 +31,6 @@ import { DORMER_GABLE_MATERIAL_INDEX, generateDormerGeometry, } from './csg-geometry' -import DormerWindowAssembly from './window-assembly' const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { const ref = useRef(null!) @@ -117,20 +116,6 @@ 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 @@ -151,16 +136,6 @@ 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, ]) @@ -208,15 +183,6 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { receiveShadow {...handlers} /> - {hostedWindows.length === 0 && ( - - )} {hostedWindows.map((window) => ( { 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) 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/lean-to-extension/assembly.test.ts b/packages/nodes/src/lean-to-extension/assembly.test.ts index 10f9ca8f29..00d4d04ee5 100644 --- a/packages/nodes/src/lean-to-extension/assembly.test.ts +++ b/packages/nodes/src/lean-to-extension/assembly.test.ts @@ -606,6 +606,46 @@ 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', diff --git a/packages/nodes/src/lean-to-extension/assembly.ts b/packages/nodes/src/lean-to-extension/assembly.ts index f817d29e10..2ef3c1566d 100644 --- a/packages/nodes/src/lean-to-extension/assembly.ts +++ b/packages/nodes/src/lean-to-extension/assembly.ts @@ -34,6 +34,7 @@ import { resolveLeanToCornerJoints, } from './corner-joint' import { resolveLeanToLayout } from './layout' +import { isLeanToPostOmitted } from './post-omissions' const MANAGED_BY_KEY = 'managedByLeanTo' const MANAGED_ROLE_KEY = 'leanToRole' @@ -252,7 +253,7 @@ function siteGroundYInLevelFrame( export function resolveLeanToPostBaseY( leanTo: LeanToExtensionNode, - wall: WallNode, + wall: WallNode | undefined, nodes: Record, index: number, side: LeanToPostSide = 'low', @@ -266,11 +267,11 @@ export function resolveLeanToPostBaseY( export function resolveLeanToPostBaseYAtLocalPosition( leanTo: LeanToExtensionNode, - wall: WallNode, + wall: WallNode | undefined, nodes: Record, localPosition: readonly [number, number, number], ): number { - const levelId = wall.parentId + const levelId = wall?.parentId ?? leanTo.parentId if (!levelId || nodes[levelId]?.type !== 'level') return 0 const postX = localPosition[0] @@ -278,16 +279,25 @@ export function resolveLeanToPostBaseYAtLocalPosition( const leanCos = Math.cos(leanRotation) const leanSin = Math.sin(leanRotation) const postZ = localPosition[2] - const wallLocalX = leanTo.position[0] + postX * leanCos + postZ * leanSin - const wallLocalZ = leanTo.position[2] - postX * leanSin + postZ * leanCos - const wallAngle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) - const wallCos = Math.cos(wallAngle) - const wallSin = Math.sin(wallAngle) - const position: [number, number, number] = [ - wall.start[0] + wallLocalX * wallCos - wallLocalZ * wallSin, - 0, - wall.start[1] + wallLocalX * wallSin + wallLocalZ * wallCos, - ] + const position: [number, number, number] = wall + ? (() => { + const wallLocalX = leanTo.position[0] + postX * leanCos + postZ * leanSin + const wallLocalZ = leanTo.position[2] - postX * leanSin + postZ * leanCos + const wallAngle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const wallCos = Math.cos(wallAngle) + const wallSin = Math.sin(wallAngle) + return [ + wall.start[0] + wallLocalX * wallCos - wallLocalZ * wallSin, + 0, + wall.start[1] + wallLocalX * wallSin + wallLocalZ * wallCos, + ] + })() + : [ + leanTo.position[0] + postX * leanCos + postZ * leanSin, + 0, + leanTo.position[2] - postX * leanSin + postZ * leanCos, + ] + const wallAngle = wall ? Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) : 0 const support = spatialGridManager.getSlabSupportForItem( levelId, position, @@ -299,7 +309,10 @@ export function resolveLeanToPostBaseYAtLocalPosition( ? 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 ) } @@ -361,6 +374,7 @@ 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 @@ -392,6 +406,9 @@ export type LeanToRoofSegmentLayoutPatch = Pick< | 'shedSideInfillMaxX' | 'shedFootprintPieces' | 'shedOpenEndSides' + | 'managedByParent' + | 'wallShell' + | 'shedInsetEndPanels' | 'trim' | 'metadata' > @@ -482,6 +499,9 @@ export function leanToRoofSegmentLayoutPatch( shedSideInfillMaxX: layout.span / 2 + sideMemberFaceInset - roofCenterX, shedFootprintPieces: hasShapedCorner ? roofPieces : undefined, shedOpenEndSides: jointSides.length > 0 ? jointSides : undefined, + managedByParent: true, + wallShell: 'omit', + shedInsetEndPanels: true, metadata: managedMetadata(leanTo, 'roof-segment'), trim: { left: 0, @@ -744,7 +764,12 @@ export function createLeanToAssembly( createManagedLeanToPost(leanTo, index, 'low'), ) for (const joint of Object.values(cornerJoints)) { - if (joint?.sharedPostOwner) posts.push(createManagedLeanToCornerPost(leanTo, joint)) + if ( + joint?.sharedPostOwner && + !isLeanToPostOmitted(leanTo, 'low', leanToCornerPostIndex(joint.side)) + ) { + posts.push(createManagedLeanToCornerPost(leanTo, joint)) + } } if (leanTo.highSideMode === 'independent-high-beam') { posts.push( diff --git a/packages/nodes/src/lean-to-extension/definition.test.ts b/packages/nodes/src/lean-to-extension/definition.test.ts index ad48925bce..05f00a7253 100644 --- a/packages/nodes/src/lean-to-extension/definition.test.ts +++ b/packages/nodes/src/lean-to-extension/definition.test.ts @@ -68,7 +68,45 @@ function pitchHandle(): LinearResizeHandle { 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) diff --git a/packages/nodes/src/lean-to-extension/definition.ts b/packages/nodes/src/lean-to-extension/definition.ts index 765ebcebeb..d19a52ffee 100644 --- a/packages/nodes/src/lean-to-extension/definition.ts +++ b/packages/nodes/src/lean-to-extension/definition.ts @@ -14,7 +14,7 @@ import { 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 { @@ -112,6 +112,13 @@ function highEdgeHeightPatch( 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 { @@ -252,14 +259,18 @@ function spanPatch( } } 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, ], } } @@ -364,9 +375,35 @@ function circularRadiusHandle(side: 'left' | 'right'): 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', @@ -392,7 +429,7 @@ leanToExtensionHandles.push(circularRadiusHandle('right'), circularRadiusHandle( export const leanToExtensionDefinition: NodeDefinition = { kind: 'lean-to-extension', - schemaVersion: 9, + schemaVersion: 11, schema: LeanToExtensionNode, category: 'structure', snapProfile: 'structural', @@ -431,18 +468,22 @@ export const leanToExtensionDefinition: NodeDefinition import('./move-tool') }, preview: () => import('./preview'), tool: () => import('./tool'), toolHints: [ - { key: 'Left click', label: 'Attach to wall or conical roof base' }, + { key: 'Left click', label: 'Attach to wall/slab edge or place freestanding' }, + { key: 'R / T', label: 'Rotate freestanding' }, { key: 'Esc', label: 'Cancel' }, ], presentation: { - label: 'Lean-to Extension', + label: 'Lean-to Canopy', description: - 'An open mono-pitch roof attached to a wall or wrapped around a conical roof base.', + 'An attached or freestanding mono-pitch canopy with managed beams, posts, and drainage.', icon: { kind: 'url', src: '/icons/lean-to-extension.webp' }, paletteSection: 'structure', paletteGroup: 'roof-features', @@ -450,6 +491,6 @@ export const leanToExtensionDefinition: NodeDefinition = { start({ node, nodes, payload, initialPlanPoint, sceneApi }) { + if (!sceneApi) return { affectedIds: [], apply() {}, canCommit: () => false } const wall = node.parentId ? (nodes[node.parentId as AnyNodeId] as WallNode | undefined) : undefined - if (wall?.type !== 'wall' || !sceneApi) { - return { affectedIds: [], apply() {}, canCommit: () => false } - } const { dimension, side = 1 } = payload as ResizePayload const outwardSign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 let along: readonly [number, number] let outward: readonly [number, number] - // On a curved host the drag axes are the wall arc's tangent / normal at - // the lean-to's along-wall position, not the straight chord direction. - if (isCurvedWall(wall)) { + if (wall?.type === 'wall' && isCurvedWall(wall)) { const arcLength = Math.max(1e-6, getWallCurveLength(wall)) const t = Math.max(0, Math.min(1, node.position[0] / arcLength)) const frame = getWallCurveFrameAt(wall, t) along = [frame.tangent.x, frame.tangent.y] outward = [frame.normal.x * outwardSign, frame.normal.y * outwardSign] - } else { + } else if (wall?.type === 'wall') { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] const length = Math.max(1e-6, Math.hypot(dx, dz)) along = [dx / length, dz / length] outward = [-along[1] * outwardSign, along[0] * outwardSign] + } else { + const cos = Math.cos(node.rotation[1]) + const sin = Math.sin(node.rotation[1]) + along = [cos, -sin] + outward = [sin, cos] } const axis = dimension === 'projection' ? outward : along const initialAxis = initialPlanPoint[0] * axis[0] + initialPlanPoint[1] * axis[1] @@ -54,32 +58,55 @@ export const leanToResizeAffordance: FloorplanAffordance = const raw = initialValue + (currentAxis - initialAxis) * side const step = modifiers.altKey ? 0 : getSegmentGridStep() const value = Math.max(0.5, step > 0 ? snapScalar(raw, step) : raw) - lastPatch = - dimension === 'projection' - ? { projection: value, ...deriveLeanToResizePatch(node, { projection: value }) } - : (() => { - const proposal = resolveLeanToSpanResizeProposal({ - node, - wall, - rawSpan: value, - side: side > 0 ? 'right' : 'left', - edgeSnapTargets: modifiers.altKey - ? [] - : resolveLeanToEdgeSnapTargets(node, wall, nodes), - }) - return { - span: proposal.span, - autoSpan: false, - position: proposal.position, - ...(proposal.target - ? { - highEdgeHeight: proposal.highEdgeHeight, - lowEdgeHeight: proposal.lowEdgeHeight, - pitch: proposal.pitch, - } - : {}), + 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) }, @@ -91,3 +118,40 @@ 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 initialAngle = Math.atan2( + initialPlanPoint[1] - node.position[2], + initialPlanPoint[0] - node.position[0], + ) + let lastRotation = node.rotation[1] + return { + affectedIds: [nodeId], + apply({ planPoint }) { + const delta = rotateAffordanceDelta({ + center: [node.position[0], node.position[2]], + 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 index 257dd08b6d..1d3064bcd3 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-move.test.ts +++ b/packages/nodes/src/lean-to-extension/floorplan-move.test.ts @@ -2,15 +2,19 @@ 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() @@ -18,6 +22,87 @@ afterEach(() => { }) 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, 2.2, + ]) + 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({ diff --git a/packages/nodes/src/lean-to-extension/floorplan-move.ts b/packages/nodes/src/lean-to-extension/floorplan-move.ts index 9ea79a24eb..d9d5019f9b 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-move.ts +++ b/packages/nodes/src/lean-to-extension/floorplan-move.ts @@ -11,6 +11,7 @@ import { import { getSegmentGridStep, isGridSnapActive } from '@pascal-app/editor' import { resolveLeanToEdgeSnapTargets, resolveLeanToMoveProposal } from './layout' import { leanToManagedPreviewOverrides } from './managed-preview' +import { moveLeanToAlongSlabEdge } from './placement' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' // Arc-length along the wall centerline to the point on it nearest the @@ -61,7 +62,49 @@ export const leanToFloorplanMoveTarget: FloorplanMoveTarget return { 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: [snap(planPoint[0]), node.position[1], 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) : (() => { diff --git a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx index 06194c36a7..ec908bbdc1 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx +++ b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx @@ -4,13 +4,14 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core' import { getWallCurveFrameAt, getWallCurveLength, isCurvedWall } from '@pascal-app/core' import { type FloorplanToolContext, + getSegmentGridStep, + isGridSnapActive, markToolCancelConsumed, triggerSFX, useEditor, useInteractionScope, } from '@pascal-app/editor' import { useCallback, useEffect, useRef, useState } from 'react' -import { findClosestWallInPlan } from '../shared/wall-attach-target' import { bendLocalPoint, isCurvedLeanTo } from './arc' import { createLeanToAssembly } from './assembly' import { @@ -19,22 +20,17 @@ import { isConicalLeanToHostOccupied, } from './conical-host' import { leanToFacetCount } from './geometry' -import { resolveLeanToSpanArc, resolveLeanToWallPlacement } from './layout' -import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' +import { resolveLeanToSpanArc } from './layout' import { - applyLeanToAvailableWallSpan, - applyLeanToRoofAttachment, - applyLeanToWallAutoSpan, - clearLeanToRoofAttachment, - resolveLeanToHostRoof, - resolveLeanToRoofAttachment, -} from './roof-attachment' -import type { LeanToExtensionNode } from './schema' + type LeanToPlanPlacementTarget, + nextLeanToPlacementRotation, + resolveLeanToCommitTarget, + resolveLeanToPlanPlacement, +} from './placement' +import { resolveLeanToHostRoof } from './roof-attachment' type PlanPoint = [number, number] -type PlanTarget = { - node: LeanToExtensionNode - valid: boolean +type PlanTarget = LeanToPlanPlacementTarget & { conicalHost?: ConicalLeanToPlanHost } @@ -53,6 +49,7 @@ const FloorplanLeanToExtensionTool = ({ }: FloorplanToolContext) => { const groupRef = useRef(null) const targetRef = useRef(null) + const rotationRef = useRef(0) const [target, setTarget] = useState(null) const clearTarget = useCallback(() => { @@ -66,6 +63,8 @@ const FloorplanLeanToExtensionTool = ({ const svg = group?.ownerSVGElement if (!(group && svg)) return useInteractionScope.getState().begin({ kind: 'drafting', tool: 'lean-to-extension' }) + rotationRef.current = 0 + let lastFreestandingEvent: PointerEvent | null = null const consume = (event: Event) => { event.preventDefault() @@ -86,29 +85,20 @@ const FloorplanLeanToExtensionTool = ({ conicalHost, } } - const hit = findClosestWallInPlan(point, nodes, activeLevelId) - if (!hit) return null - const wallPlacement = resolveLeanToWallPlacement(hit.wall, hit.localX, hit.side) - if (!wallPlacement) return null - const attachment = resolveLeanToRoofAttachment(wallPlacement, hit.wall, nodes) - const autoSpannedNode = attachment - ? applyLeanToRoofAttachment(wallPlacement, attachment) - : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), hit.wall) - const attachedNode = applyLeanToAvailableWallSpan( - autoSpannedNode, - hit.wall, + const step = !event.altKey && isGridSnapActive() ? getSegmentGridStep() : 0 + const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) + return resolveLeanToPlanPlacement({ + activeLevelId, + freestandingPoint: [snap(point[0]), snap(point[1])], + freestandingRotationY: rotationRef.current, nodes, - wallPlacement.position[0], - ) - const node = resolveLeanToEndAbutments(attachedNode, hit.wall, nodes) - return { - node, - valid: leanToPlacementConflicts(node, hit.wall, nodes).length === 0, - } + point, + }) } const update = (event: PointerEvent) => { consume(event) const node = resolveEvent(event) + lastFreestandingEvent = node?.node.hostKind === 'freestanding' ? event : null targetRef.current = node setTarget(node) } @@ -118,7 +108,7 @@ const FloorplanLeanToExtensionTool = ({ const commit = (event: MouseEvent) => { if (event.button !== 0) return consume(event) - const resolved = resolveEvent(event) ?? targetRef.current + const resolved = resolveLeanToCommitTarget(targetRef.current, resolveEvent(event)) if (!resolved?.valid) return const { node } = resolved const nodes = sceneApi.nodes() as Record @@ -134,25 +124,53 @@ const FloorplanLeanToExtensionTool = ({ triggerSFX('sfx:structure-build') if (useEditor.getState().getContinuation('point') !== 'repeat') finishTool() } - const cancel = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopImmediatePropagation() + markToolCancelConsumed() + finishTool() + return + } + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return + } + if (!lastFreestandingEvent) return + + const nextRotation = nextLeanToPlacementRotation( + rotationRef.current, + event.key, + event.metaKey || event.ctrlKey, + ) + if (nextRotation === rotationRef.current) return + event.preventDefault() - event.stopImmediatePropagation() - markToolCancelConsumed() - finishTool() + rotationRef.current = nextRotation + triggerSFX('sfx:item-rotate') + const resolved = resolveEvent(lastFreestandingEvent) + targetRef.current = resolved + setTarget(resolved) + } + const onPointerLeave = (event: PointerEvent) => { + lastFreestandingEvent = null + clearTarget() } svg.addEventListener('pointerdown', onPointerDown, true) svg.addEventListener('pointermove', update, true) - svg.addEventListener('pointerleave', clearTarget, true) + svg.addEventListener('pointerleave', onPointerLeave, true) svg.addEventListener('click', commit, true) - window.addEventListener('keydown', cancel, true) + window.addEventListener('keydown', onKeyDown, true) return () => { svg.removeEventListener('pointerdown', onPointerDown, true) svg.removeEventListener('pointermove', update, true) - svg.removeEventListener('pointerleave', clearTarget, true) + svg.removeEventListener('pointerleave', onPointerLeave, true) svg.removeEventListener('click', commit, true) - window.removeEventListener('keydown', cancel, true) + window.removeEventListener('keydown', onKeyDown, true) clearTarget() useInteractionScope .getState() @@ -199,6 +217,33 @@ const FloorplanLeanToExtensionTool = ({ const node = target?.node const wall = node?.parentId ? sceneApi.get(node.parentId as AnyNodeId) : null + if (node && (node.hostKind === 'slab-edge' || node.hostKind === 'freestanding')) { + const cos = Math.cos(node.rotation[1]) + const sin = Math.sin(node.rotation[1]) + const toWorld = (localX: number, localZ: number): [number, number] => [ + node.position[0] + localX * cos + localZ * sin, + node.position[2] - localX * sin + localZ * cos, + ] + const points = [ + toWorld(-(node.span / 2 + node.leftOverhang), -node.highOverhang), + toWorld(node.span / 2 + node.rightOverhang, -node.highOverhang), + toWorld(node.span / 2 + node.rightOverhang, node.projection + node.lowOverhang), + toWorld(-(node.span / 2 + node.leftOverhang), node.projection + node.lowOverhang), + ] + return ( + + point.join(',')).join(' ')} + stroke={target.valid ? '#0ea5e9' : '#ef4444'} + strokeDasharray="6 4" + strokeWidth={2} + vectorEffect="non-scaling-stroke" + /> + + ) + } if (!(node && wall?.type === 'wall')) return const sign = Math.cos(node.rotation[1]) >= 0 ? 1 : -1 diff --git a/packages/nodes/src/lean-to-extension/floorplan.test.ts b/packages/nodes/src/lean-to-extension/floorplan.test.ts index ecd1098463..bc5ab7bec1 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.test.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.test.ts @@ -3,6 +3,8 @@ import { type GeometryContext, getWallCurveFrameAt, getWallCurveLength, + LeanToExtensionNode, + LevelNode, RoofNode, RoofSegmentNode, WallNode, @@ -12,6 +14,39 @@ import { buildLeanToExtensionFloorplan } from './floorplan' import { resolveLeanToWallPlacement } from './layout' describe('curved lean-to floorplan', () => { + test('draws a freestanding canopy in its level plan frame', () => { + const level = LevelNode.parse({ id: 'level_free_canopy', level: 0 }) + const node = LeanToExtensionNode.parse({ + parentId: level.id, + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + position: [10, 0, 20], + rotation: [0, Math.PI / 2, 0], + span: 2, + projection: 1, + highOverhang: 0, + lowOverhang: 0, + leftOverhang: 0, + rightOverhang: 0, + }) + const geometry = buildLeanToExtensionFloorplan(node, { + children: [], + parent: level, + resolve: () => undefined, + siblings: [], + } as GeometryContext) + + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + const roof = geometry.children.find((child) => child.kind === 'polygon') + expect(roof?.kind).toBe('polygon') + if (roof?.kind !== 'polygon') return + expect(Math.min(...roof.points.map((point) => point[0]))).toBeCloseTo(10, 6) + expect(Math.max(...roof.points.map((point) => point[0]))).toBeCloseTo(11, 6) + expect(Math.min(...roof.points.map((point) => point[1]))).toBeCloseTo(19, 6) + expect(Math.max(...roof.points.map((point) => point[1]))).toBeCloseTo(21, 6) + }) + test('matches the committed back-side frame direction', () => { const wall = WallNode.parse({ start: [0, 0], end: [6, 0], curveOffset: 1, thickness: 0.2 }) const wallLength = getWallCurveLength(wall) diff --git a/packages/nodes/src/lean-to-extension/floorplan.ts b/packages/nodes/src/lean-to-extension/floorplan.ts index 18341aa127..5beb20f00c 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.ts @@ -14,6 +14,7 @@ import { import { bendLocalPoint, isCurvedLeanTo } from './arc' import { leanToFacetCount } from './geometry' import { resolveLeanToLayout } from './layout' +import { isLeanToPostOmitted } from './post-omissions' function conicalSegmentPlanPose( segment: RoofSegmentNode, @@ -89,7 +90,8 @@ function buildConicalLeanToFloorplan( 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', @@ -116,6 +118,92 @@ function buildConicalLeanToFloorplan( return { kind: 'group', children } } +function buildLevelLeanToFloorplan( + node: LeanToExtensionNode, + ctx: GeometryContext, +): FloorplanGeometry { + const layout = resolveLeanToLayout(node) + const rotationY = node.rotation[1] + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + const toWorld = (localX: number, localZ: number): FloorplanPoint => [ + node.position[0] + localX * cos + localZ * sin, + node.position[2] - localX * sin + localZ * cos, + ] + const left = layout.span / 2 + node.leftOverhang + const right = layout.span / 2 + node.rightOverhang + const high = node.highOverhang + const low = layout.projection + node.lowOverhang + const points: FloorplanPoint[] = [ + toWorld(-left, -high), + toWorld(right, -high), + toWorld(right, low), + toWorld(-left, low), + ] + const selected = ctx.viewState?.selected ?? false + const stroke = selected ? '#f97316' : '#475569' + const children: FloorplanGeometry[] = [ + { + kind: 'polygon', + points, + fill: selected ? '#ffedd5' : '#e2e8f0', + fillOpacity: 0.65, + stroke, + strokeWidth: selected ? 2 : 1.25, + vectorEffect: 'non-scaling-stroke', + }, + { + kind: 'polyline', + points: [ + toWorld(-layout.beamSpan / 2, layout.beamZ), + toWorld(layout.beamSpan / 2, layout.beamZ), + ], + stroke, + strokeWidth: selected ? 3 : 2, + vectorEffect: 'non-scaling-stroke', + }, + ] + const addPostRow = (localZ: number, side: 'low' | 'high') => { + for (const [index, x] of layout.postXs.entries()) { + if (isLeanToPostOmitted(node, side, index)) continue + const [postX, postZ] = toWorld(x, localZ) + children.push({ + kind: 'rect', + x: postX - node.postWidth / 2, + y: postZ - node.postDepth / 2, + width: node.postWidth, + height: node.postDepth, + fill: stroke, + stroke, + strokeWidth: 1, + vectorEffect: 'non-scaling-stroke', + }) + } + } + addPostRow(layout.beamZ, 'low') + if (node.highSideMode === 'independent-high-beam') addPostRow(0, 'high') + if (selected) { + children.push({ + kind: 'move-arrow', + point: toWorld(0, layout.roofRun + 0.12), + angle: Math.atan2(Math.cos(rotationY), Math.sin(rotationY)), + affordance: 'lean-to-resize', + payload: { dimension: 'projection' }, + }) + if (node.hostKind === 'freestanding') { + const point = toWorld(right + 0.25, low + 0.25) + children.push({ + kind: 'rotate-arrow', + point, + angle: Math.atan2(point[1] - node.position[2], point[0] - node.position[0]), + affordance: 'lean-to-rotate', + pivot: [node.position[0], node.position[2]], + }) + } + } + return { kind: 'group', children } +} + export function buildLeanToExtensionFloorplan( node: LeanToExtensionNode, ctx: GeometryContext, @@ -127,6 +215,12 @@ export function buildLeanToExtensionFloorplan( ) { 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 @@ -229,7 +323,8 @@ export function buildLeanToExtensionFloorplan( vectorEffect: 'non-scaling-stroke', }) - for (const x of layout.postXs) { + for (const [index, x] of layout.postXs.entries()) { + if (isLeanToPostOmitted(node, 'low', index)) continue const [postX, postZ] = toWorld(x, layout.beamZ) children.push({ kind: 'rect', diff --git a/packages/nodes/src/lean-to-extension/index.ts b/packages/nodes/src/lean-to-extension/index.ts index a808af789f..fb815b9ad1 100644 --- a/packages/nodes/src/lean-to-extension/index.ts +++ b/packages/nodes/src/lean-to-extension/index.ts @@ -7,4 +7,11 @@ export { resolveLeanToLayout, resolveLeanToWallPlacement, } from './layout' +export { + findLeanToSlabEdgePlacement, + moveLeanToAlongSlabEdge, + reconcileLeanToSlabEdgePlacement, + resolveLeanToFreestandingPlacement, + resolveLeanToSlabEdgePlacement, +} from './placement' export { LeanToExtensionNode } from './schema' diff --git a/packages/nodes/src/lean-to-extension/move-tool.tsx b/packages/nodes/src/lean-to-extension/move-tool.tsx index b32cce2eb8..9b10e28551 100644 --- a/packages/nodes/src/lean-to-extension/move-tool.tsx +++ b/packages/nodes/src/lean-to-extension/move-tool.tsx @@ -4,6 +4,9 @@ import { type AnyNode, type AnyNodeId, emitter, + type GridEvent, + getLevelElevations, + getWallBaseElevationForNodes, type LeanToExtensionNode, type SceneApi, sceneRegistry, @@ -12,25 +15,45 @@ import { type WallNode, } from '@pascal-app/core' import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' -import { useEffect } from 'react' -import { resolveLeanToEdgeSnapTargets, resolveLeanToMoveProposal } from './layout' -import { leanToManagedPreviewOverrides } from './managed-preview' +import { useLayoutEffect, useState } from 'react' +import { leanToExtensionGeometryKey } from './geometry' +import { + leanToWallLocalPose, + resolveLeanToEdgeSnapTargets, + resolveLeanToMoveProposal, +} from './layout' +import { moveLeanToAlongSlabEdge } 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 previewIds = new Set() 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 @@ -40,107 +63,196 @@ const MoveLeanToExtensionTool = ({ node, sceneApi }: MoveLeanToExtensionProps) = }) }) - const resolvePatch = (event: WallEvent) => { - if (event.node.id !== wall.id) return null - dragStartLocalY ??= event.localPosition[1] - const rawLocalX = event.localPosition[0] - 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 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 proposal = resolveLeanToMoveProposal({ - node, - wall, - rawLocalX, - 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, + 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 + } + + 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 commit = () => { + if (!lastPatch) return + sceneApi.update(node.id as AnyNodeId, lastPatch as Partial) + triggerSFX('sfx:structure-build') + useEditor.getState().setMovingNode(null) + } + + 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, - }, - wall, - nodes, - ) - const patch: Partial = { - position, - highEdgeHeight: proposal.highEdgeHeight, - lowEdgeHeight: proposal.lowEdgeHeight, - connectionOffset, - autoSpan: false, - leftEndCondition: candidate.leftEndCondition, - rightEndCondition: candidate.rightEndCondition, - downspoutPosition: candidate.downspoutPosition, + 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 previewEntries: ReadonlyArray]> = [ - [node.id as AnyNodeId, patch as Partial], - ...leanToManagedPreviewOverrides(node, patch, sceneApi), - ] - useLiveNodeOverrides.getState().setMany(previewEntries) - for (const [id] of previewEntries) { - previewIds.add(id) - sceneApi.markDirty(id) + 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() } - lastPatch = - event.nativeEvent.altKey || leanToPlacementConflicts(candidate, wall, nodes).length === 0 - ? patch - : null - return lastPatch - } - - const onMove = (event: WallEvent) => { - resolvePatch(event) - } - const onClick = (event: WallEvent) => { - const patch = resolvePatch(event) - if (!patch) return - event.stopPropagation() - for (const id of previewIds) useLiveNodeOverrides.getState().clear(id) - sceneApi.update(node.id as AnyNodeId, patch as Partial) - triggerSFX('sfx:structure-build') - useEditor.getState().setMovingNode(null) } - emitter.on('wall:move', onMove) - emitter.on('wall:enter', onMove) - emitter.on('wall:click', onClick) - return () => { - emitter.off('wall:move', onMove) - emitter.off('wall:enter', onMove) - emitter.off('wall:click', onClick) - for (const id of previewIds) { - useLiveNodeOverrides.getState().clear(id) - sceneApi.markDirty(id) + 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: [snap(event.localPosition[0]), node.position[1], 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() } - for (const restore of restoreRaycasts) restore() - lastPatch = null } + + return cleanUp }, [node, sceneApi]) - return null + if (!preview) return null + return ( + + + + ) } export default MoveLeanToExtensionTool diff --git a/packages/nodes/src/lean-to-extension/parametrics.ts b/packages/nodes/src/lean-to-extension/parametrics.ts index b8061445af..c18e7fb86a 100644 --- a/packages/nodes/src/lean-to-extension/parametrics.ts +++ b/packages/nodes/src/lean-to-extension/parametrics.ts @@ -75,6 +75,12 @@ export const leanToExtensionParametrics: ParametricDescriptor node.hostKind !== 'freestanding', + }, { key: 'span', label: 'Width', @@ -103,7 +114,7 @@ export const leanToExtensionParametrics: ParametricDescriptor node.hostKind === 'wall', }, { key: 'highSideMode', - label: 'Wall side', + label: 'High-side support', kind: 'enum', options: ['wall-ledger', 'independent-high-beam'], + visibleIf: (node) => node.hostKind === 'wall', }, { key: 'connectionOffset', @@ -310,7 +323,7 @@ export const leanToExtensionParametrics: ParametricDescriptor { + test('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, 6], + 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('maps R and T to opposite 45 degree placement rotations', () => { + expect(nextLeanToPlacementRotation(0, 'r')).toBeCloseTo(Math.PI / 4) + expect(nextLeanToPlacementRotation(0, 't')).toBeCloseTo(-Math.PI / 4) + }) + + test('commits the visible ghost when the click ray resolves a different target', () => { + const visibleWallTarget = { kind: 'wall', span: 9 } + const clickRayTarget = { kind: 'freestanding', span: 4 } + + expect(resolveLeanToCommitTarget(visibleWallTarget, clickRayTarget)).toBe(visibleWallTarget) + }) + + test('snaps a ground-plane target near a wall before falling back to freestanding', () => { + const building = BuildingNode.parse({ id: 'building_wall_snap' }) + const wallId = 'wall_snap_target' + const level = LevelNode.parse({ + id: 'level_wall_snap', + parentId: building.id, + level: 0, + height: 3, + children: [wallId], + }) + const wall = WallNode.parse({ + id: wallId, + parentId: level.id, + start: [0, 0], + end: [8, 0], + height: 3, + }) + const nodes = { + [building.id]: building, + [level.id]: level, + [wall.id]: wall, + } as Record + + const target = resolveLeanToPlanPlacement({ + activeLevelId: level.id, + freestandingPoint: [3, 0], + nodes, + point: [3, 0.2], + }) + + expect(target.valid).toBe(true) + expect(target.wall?.id).toBe(wall.id) + expect(target.node).toMatchObject({ + parentId: wall.id, + hostKind: 'wall', + highSideMode: 'wall-ledger', + }) + }) + + test('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..93088b0c59 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/placement.ts @@ -0,0 +1,282 @@ +import { + type AnyNode, + type AnyNodeId, + getLevelElevations, + LeanToExtensionNode, + type SlabNode, + type WallNode, +} from '@pascal-app/core' +import { findClosestWallInPlan } from '../shared/wall-attach-target' +import { leanToLowEdgeHeight, 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 +} + +const PLACEMENT_ROTATION_STEP = Math.PI / 4 + +export function nextLeanToPlacementRotation( + current: number, + key: string, + hasShortcutModifier = false, +): number { + if (hasShortcutModifier) return current + const direction = key === 'r' || key === 'R' ? 1 : key === 't' || key === 'T' ? -1 : 0 + if (direction === 0) return current + return (Math.round(current / PLACEMENT_ROTATION_STEP) + direction) * PLACEMENT_ROTATION_STEP +} + +export function resolveLeanToFreestandingPlacement( + levelId: string, + point: readonly [number, number], + rotationY = 0, +): LeanToExtensionNode { + const parsed = LeanToExtensionNode.parse({ + name: 'Freestanding Lean-to Canopy', + parentId: levelId, + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + connectionMode: 'manual', + autoSpan: false, + position: [point[0], 0, point[1]], + rotation: [0, rotationY, 0], + }) + return { + ...parsed, + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } +} + +export function resolveLeanToPlanPlacement({ + activeLevelId, + freestandingPoint, + freestandingRotationY = 0, + nodes, + point, +}: { + activeLevelId: AnyNodeId + freestandingPoint: readonly [number, number] + freestandingRotationY?: number + nodes: Record + point: readonly [number, number] +}): LeanToPlanPlacementTarget { + const hit = findClosestWallInPlan(point, nodes, activeLevelId) + if (hit) { + const wallPlacement = resolveLeanToWallPlacement(hit.wall, hit.localX, hit.side) + if (wallPlacement) { + const attachment = resolveLeanToRoofAttachment(wallPlacement, hit.wall, nodes) + const autoSpannedNode = attachment + ? applyLeanToRoofAttachment(wallPlacement, attachment) + : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), hit.wall) + const attachedNode = applyLeanToAvailableWallSpan( + autoSpannedNode, + hit.wall, + nodes, + wallPlacement.position[0], + ) + const node = resolveLeanToEndAbutments(attachedNode, hit.wall, nodes) + return { + node, + valid: leanToPlacementConflicts(node, hit.wall, nodes).length === 0, + wall: hit.wall, + } + } + } + + const slabAttached = findLeanToSlabEdgePlacement(point, nodes, activeLevelId) + if (slabAttached) return { node: slabAttached, valid: true } + + return { + node: resolveLeanToFreestandingPlacement( + activeLevelId, + freestandingPoint, + freestandingRotationY, + ), + valid: true, + } +} + +export function resolveLeanToSlabEdgePlacement({ + activeLevelId, + edgeIndex, + edgeT, + nodes, + slab, +}: { + activeLevelId: string + edgeIndex: number + edgeT: number + nodes: Record + slab: SlabNode +}): LeanToExtensionNode | null { + const activeLevel = getLevelElevations(nodes).get(activeLevelId) + const hostLevel = slab.parentId ? getLevelElevations(nodes).get(slab.parentId) : undefined + if (!(activeLevel && hostLevel && activeLevel.buildingId === hostLevel.buildingId)) return null + + const start = slab.polygon[edgeIndex] + const end = slab.polygon[(edgeIndex + 1) % slab.polygon.length] + if (!(start && end)) return null + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const edgeLength = Math.hypot(dx, dz) + if (edgeLength < 0.6) return null + + const t = Math.max(0, Math.min(1, edgeT)) + const area = slab.polygon.reduce((sum, point, index) => { + const next = slab.polygon[(index + 1) % slab.polygon.length]! + return sum + point[0] * next[1] - next[0] * point[1] + }, 0) + const winding = area >= 0 ? 1 : -1 + const outwardX = (winding * dz) / edgeLength + const outwardZ = (-winding * dx) / edgeLength + const highEdgeHeight = hostLevel.baseY - activeLevel.baseY + slab.elevation - slab.thickness + if (highEdgeHeight < 0.8 || highEdgeHeight > 10) return null + + const parsed = LeanToExtensionNode.parse({ + name: 'Slab-attached Lean-to Canopy', + parentId: activeLevelId, + hostKind: 'slab-edge', + hostSlabId: slab.id, + hostSlabEdgeIndex: edgeIndex, + hostSlabEdgeT: t, + highSideMode: 'wall-ledger', + connectionMode: 'manual', + autoSpan: true, + span: Math.max(0.5, edgeLength - 0.1), + position: [start[0] + dx * t, 0, start[1] + dz * t], + rotation: [0, Math.atan2(outwardX, outwardZ), 0], + highEdgeHeight, + }) + return { + ...parsed, + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + lowEdgeHeight: leanToLowEdgeHeight(parsed), + } +} + +export function findLeanToSlabEdgePlacement( + point: readonly [number, number], + nodes: Record, + activeLevelId: string, + maxDistance = 0.35, +): LeanToExtensionNode | null { + let best: { distance: number; node: LeanToExtensionNode } | null = null + for (const candidate of Object.values(nodes)) { + if (candidate.type !== 'slab' || candidate.recessed || candidate.polygon.length < 2) continue + for (let edgeIndex = 0; edgeIndex < candidate.polygon.length; edgeIndex++) { + const start = candidate.polygon[edgeIndex]! + const end = candidate.polygon[(edgeIndex + 1) % candidate.polygon.length]! + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSq = dx * dx + dz * dz + if (lengthSq <= 1e-9) continue + const edgeT = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSq), + ) + const edgeX = start[0] + dx * edgeT + const edgeZ = start[1] + dz * edgeT + const distance = Math.hypot(point[0] - edgeX, point[1] - edgeZ) + if (distance > maxDistance || (best && distance >= best.distance)) continue + const node = resolveLeanToSlabEdgePlacement({ + activeLevelId, + edgeIndex, + edgeT, + nodes, + slab: candidate, + }) + if (node) best = { distance, node } + } + } + return best?.node ?? null +} + +export function reconcileLeanToSlabEdgePlacement( + node: LeanToExtensionNode, + nodes: Record, +): LeanToExtensionNode { + if ( + node.hostKind !== 'slab-edge' || + !node.parentId || + !node.hostSlabId || + node.hostSlabEdgeIndex === undefined || + node.hostSlabEdgeT === undefined + ) { + return node + } + const slab = nodes[node.hostSlabId as AnyNodeId] + if (slab?.type !== 'slab') return node + const resolved = resolveLeanToSlabEdgePlacement({ + activeLevelId: node.parentId, + edgeIndex: node.hostSlabEdgeIndex, + edgeT: node.hostSlabEdgeT, + nodes, + slab, + }) + if (!resolved) return node + const highEdgeHeight = resolved.highEdgeHeight + node.hostHeightOffset + return { + ...node, + position: resolved.position, + rotation: resolved.rotation, + span: node.autoSpan ? resolved.span : node.span, + highEdgeHeight, + lowEdgeHeight: leanToLowEdgeHeight({ ...node, highEdgeHeight }), + highSideMode: 'wall-ledger', + connectionMode: 'manual', + hostRoofId: undefined, + hostRoofSegmentId: undefined, + hostRoofEdge: undefined, + hostRoofEdgeRange: undefined, + connectionInset: 0, + } +} + +export function moveLeanToAlongSlabEdge( + node: LeanToExtensionNode, + point: readonly [number, number], + nodes: Record, +): LeanToExtensionNode | null { + if (node.hostKind !== 'slab-edge' || !node.hostSlabId || node.hostSlabEdgeIndex === undefined) { + return null + } + const slab = nodes[node.hostSlabId as AnyNodeId] + if (slab?.type !== 'slab') return null + const start = slab.polygon[node.hostSlabEdgeIndex] + const end = slab.polygon[(node.hostSlabEdgeIndex + 1) % slab.polygon.length] + if (!(start && end)) return null + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSq = dx * dx + dz * dz + if (lengthSq <= 1e-9) return null + const hostSlabEdgeT = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSq), + ) + return reconcileLeanToSlabEdgePlacement({ ...node, hostSlabEdgeT }, nodes) +} diff --git a/packages/nodes/src/lean-to-extension/post-omissions.test.ts b/packages/nodes/src/lean-to-extension/post-omissions.test.ts new file mode 100644 index 0000000000..81061a772d --- /dev/null +++ b/packages/nodes/src/lean-to-extension/post-omissions.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + ColumnNode, + LeanToExtensionNode, + type LeanToExtensionNode as LeanToNode, +} from '@pascal-app/core' +import { isLeanToPostOmitted, leanToPostOmissionPatchesOnDelete } from './post-omissions' + +describe('isLeanToPostOmitted', () => { + test('treats a legacy node without omission data as having no omitted posts', () => { + const parsed = LeanToExtensionNode.parse({ + postLayoutMode: 'count', + postCount: 3, + }) + const { omittedPostSlots: _, ...legacy } = parsed + + expect(isLeanToPostOmitted(legacy as LeanToNode, 'low', 1)).toBe(false) + }) + + test('records the first omission on a legacy node without omission data', () => { + const parsed = LeanToExtensionNode.parse({ + postLayoutMode: 'count', + postCount: 3, + }) + const { omittedPostSlots: _, ...legacy } = parsed + const post = ColumnNode.parse({ + parentId: parsed.id, + metadata: { + leanToRole: 'post', + managedByLeanTo: parsed.id, + leanToPostIndex: 1, + leanToPostSide: 'low', + }, + }) + const nodes = { + [parsed.id]: legacy, + [post.id]: post, + } as unknown as Record + + expect(leanToPostOmissionPatchesOnDelete(post, nodes)).toEqual([ + { + id: parsed.id, + data: { + omittedPostSlots: [{ side: 'low', index: 1, layoutCount: 3 }], + }, + }, + ]) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/post-omissions.ts b/packages/nodes/src/lean-to-extension/post-omissions.ts new file mode 100644 index 0000000000..876f069e05 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/post-omissions.ts @@ -0,0 +1,58 @@ +import type { AnyNode, AnyNodeId, ColumnNode, LeanToExtensionNode } from '@pascal-app/core' +import type { LeanToPostSide } from './assembly' +import { resolveLeanToLayout } from './layout' + +function managedPostSlot(column: ColumnNode): { side: LeanToPostSide; index: number } | null { + const metadata = column.metadata + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + if (metadata.leanToRole !== 'post' || typeof metadata.managedByLeanTo !== 'string') return null + if (typeof metadata.leanToPostIndex !== 'number' || !Number.isInteger(metadata.leanToPostIndex)) { + return null + } + return { + side: metadata.leanToPostSide === 'high' ? 'high' : 'low', + index: metadata.leanToPostIndex, + } +} + +export function isLeanToPostOmitted( + leanTo: LeanToExtensionNode, + side: LeanToPostSide, + index: number, +): boolean { + const currentCount = resolveLeanToLayout(leanTo).postXs.length + return (leanTo.omittedPostSlots ?? []).some((slot) => { + if (slot.side !== side) return false + if (slot.index < 0 || index < 0) return slot.index === index + if (leanTo.hostKind === 'conical-roof') { + const normalized = slot.index / Math.max(1, slot.layoutCount) + return Math.round(normalized * currentCount) % currentCount === index + } + const normalized = slot.index / Math.max(1, slot.layoutCount - 1) + return Math.round(normalized * Math.max(1, currentCount - 1)) === index + }) +} + +export function leanToPostOmissionPatchesOnDelete( + column: ColumnNode, + nodes: Record, +): Array<{ id: AnyNodeId; data: Partial }> { + const slot = managedPostSlot(column) + if (!slot) return [] + const metadata = column.metadata as Record + const leanTo = nodes[metadata.managedByLeanTo as AnyNodeId] + if (leanTo?.type !== 'lean-to-extension' || isLeanToPostOmitted(leanTo, slot.side, slot.index)) { + return [] + } + return [ + { + id: leanTo.id as AnyNodeId, + data: { + omittedPostSlots: [ + ...(leanTo.omittedPostSlots ?? []), + { ...slot, layoutCount: resolveLeanToLayout(leanTo).postXs.length }, + ], + }, + }, + ] +} diff --git a/packages/nodes/src/lean-to-extension/preview-geometry.test.ts b/packages/nodes/src/lean-to-extension/preview-geometry.test.ts new file mode 100644 index 0000000000..042992734e --- /dev/null +++ b/packages/nodes/src/lean-to-extension/preview-geometry.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test' +import { LeanToExtensionNode, RoofSegmentNode } from '@pascal-app/core' +import { Mesh, MeshBasicMaterial } from 'three' +import { resolveConicalLeanToPlacement } from './conical-host' +import { buildLeanToExtensionGeometry } from './geometry' +import { + buildLeanToExtensionPreviewGeometry, + disposeLeanToExtensionPreviewGeometry, + LEAN_TO_GHOST_COLOR, + LEAN_TO_INVALID_GHOST_COLOR, +} from './preview-geometry' + +function previewMaterial(root: ReturnType) { + const mesh = root.children.find((child): child is Mesh => child instanceof Mesh) + expect(mesh).toBeDefined() + expect(mesh?.material).toBeInstanceOf(MeshBasicMaterial) + return mesh?.material as MeshBasicMaterial +} + +describe('lean-to placement ghost', () => { + test('uses the same placement geometry as the committed canopy', () => { + const node = LeanToExtensionNode.parse({ + highSideMode: 'independent-high-beam', + postCount: 5, + }) + const committedGeometry = buildLeanToExtensionGeometry(node) + const root = buildLeanToExtensionPreviewGeometry(node) + const meshes: Mesh[] = [] + const committedMeshes: Mesh[] = [] + root.traverse((object) => { + if (object instanceof Mesh) meshes.push(object) + }) + committedGeometry.traverse((object) => { + if (object instanceof Mesh) committedMeshes.push(object) + }) + + expect(meshes.map((mesh) => mesh.name).sort()).toEqual( + committedMeshes.map((mesh) => mesh.name).sort(), + ) + expect(new Set(meshes.map((mesh) => mesh.material)).size).toBe(1) + const material = previewMaterial(root) + expect(material.color.getHex()).toBe(LEAN_TO_GHOST_COLOR) + expect(material.depthWrite).toBe(false) + expect(material.opacity).toBe(0.3) + expect(material.transparent).toBe(true) + + disposeLeanToExtensionPreviewGeometry(root) + committedGeometry.traverse((object) => { + if (object instanceof Mesh) object.geometry.dispose() + }) + }) + + test('uses the same geometry with an invalid red material', () => { + const root = buildLeanToExtensionPreviewGeometry(LeanToExtensionNode.parse({}), true) + + const material = previewMaterial(root) + expect(material.color.getHex()).toBe(LEAN_TO_INVALID_GHOST_COLOR) + expect(material.depthWrite).toBe(false) + expect(material.opacity).toBe(0.38) + expect(material.transparent).toBe(true) + + disposeLeanToExtensionPreviewGeometry(root) + }) + + test('keeps a conical hover ghost visible over its host surface', () => { + const host = RoofSegmentNode.parse({ + roofType: 'conical', + width: 8, + depth: 8, + wallHeight: 3, + }) + const node = resolveConicalLeanToPlacement(host)! + const root = buildLeanToExtensionPreviewGeometry(node) + + expect(root.children.length).toBeGreaterThan(1) + expect(previewMaterial(root).depthTest).toBe(false) + + disposeLeanToExtensionPreviewGeometry(root) + }) +}) diff --git a/packages/nodes/src/lean-to-extension/preview-geometry.ts b/packages/nodes/src/lean-to-extension/preview-geometry.ts new file mode 100644 index 0000000000..e467fdb255 --- /dev/null +++ b/packages/nodes/src/lean-to-extension/preview-geometry.ts @@ -0,0 +1,42 @@ +import type { LeanToExtensionNode } from '@pascal-app/core' +import { type Group, type Material, Mesh, MeshBasicMaterial } from 'three' +import { buildLeanToExtensionGeometry } from './geometry' + +export const LEAN_TO_GHOST_COLOR = 0x6c_a3_ff +export const LEAN_TO_INVALID_GHOST_COLOR = 0xef_44_44 + +export function buildLeanToExtensionPreviewGeometry( + node: LeanToExtensionNode, + invalid = false, +): Group { + const group = buildLeanToExtensionGeometry(node, undefined, 'rendered', false) + group.name = 'lean-to-extension-preview' + const material = new MeshBasicMaterial({ + color: invalid ? LEAN_TO_INVALID_GHOST_COLOR : LEAN_TO_GHOST_COLOR, + depthTest: false, + depthWrite: false, + opacity: invalid ? 0.38 : 0.3, + transparent: true, + }) + const replacedMaterials = new Set() + group.traverse((object) => { + if (!(object instanceof Mesh)) return + const materials = Array.isArray(object.material) ? object.material : [object.material] + for (const source of materials) replacedMaterials.add(source) + object.material = material + }) + for (const source of replacedMaterials) source.dispose() + + return group +} + +export function disposeLeanToExtensionPreviewGeometry(root: Group): void { + const materials = new Set() + root.traverse((object) => { + if (!(object instanceof Mesh)) return + object.geometry.dispose() + const meshMaterials = Array.isArray(object.material) ? object.material : [object.material] + for (const material of meshMaterials) materials.add(material) + }) + for (const material of materials) material.dispose() +} diff --git a/packages/nodes/src/lean-to-extension/preview.tsx b/packages/nodes/src/lean-to-extension/preview.tsx index 7907962fa6..53f3c4b7b6 100644 --- a/packages/nodes/src/lean-to-extension/preview.tsx +++ b/packages/nodes/src/lean-to-extension/preview.tsx @@ -2,11 +2,11 @@ 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 { Color, type Material, Mesh } from 'three' -import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' -import { buildLeanToExtensionGeometry } from './geometry' +import { + buildLeanToExtensionPreviewGeometry, + disposeLeanToExtensionPreviewGeometry, +} from './preview-geometry' const LeanToExtensionPreview = ({ node, @@ -15,53 +15,18 @@ const LeanToExtensionPreview = ({ node: LeanToExtensionNode invalid?: boolean }) => { - 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 originals: Array<{ mesh: Mesh; material: Material | Material[] }> = [] - const ownedMaterials: Material[] = [] - built.traverse((object) => { + const built = useMemo(() => { + const next = buildLeanToExtensionPreviewGeometry(node, invalid) + next.traverse((object) => { object.layers.set(EDITOR_LAYER) object.raycast = () => {} - if (!(object instanceof Mesh)) return - originals.push({ mesh: object, material: object.material }) - const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material] - const materials = sourceMaterials.map((material) => { - const copy = material.clone() - copy.transparent = true - copy.opacity = invalid ? 0.4 : 0.5 - copy.depthWrite = false - if (invalid) { - if ('color' in copy && copy.color instanceof Color) { - copy.color.setHex(INVALID_GHOST_COLOR) - } - if ('emissive' in copy && copy.emissive instanceof Color) { - copy.emissive.setHex(INVALID_GHOST_COLOR) - } - } - ownedMaterials.push(copy) - return copy - }) - object.material = Array.isArray(object.material) ? materials : materials[0]! }) - return () => { - for (const { mesh, material } of originals) mesh.material = material - for (const material of ownedMaterials) material.dispose() - } - }, [built, invalid]) + return next + }, [invalid, node]) useEffect( () => () => { - built.traverse((object) => { - const mesh = object as { geometry?: { dispose: () => void } } - mesh.geometry?.dispose() - }) + disposeLeanToExtensionPreviewGeometry(built) }, [built], ) 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/system.test.ts b/packages/nodes/src/lean-to-extension/system.test.ts index cb06725320..2e6ce65875 100644 --- a/packages/nodes/src/lean-to-extension/system.test.ts +++ b/packages/nodes/src/lean-to-extension/system.test.ts @@ -1,20 +1,32 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, + type AnyNodeDefinition, type AnyNodeId, + BuildingNode, clearSceneHistory, createSceneApi, LeanToExtensionNode, LevelNode, + nodeRegistry, RoofNode, RoofSegmentNode, + registerNode, type SceneCommit, + SlabNode, subscribeSceneCommits, useScene, WallNode, } from '@pascal-app/core' -import { createLeanToAssembly, leanToCornerPostIndex, managedLeanToPostIndex } from './assembly' +import { columnDefinition } from '../column' +import { + createLeanToAssembly, + leanToCornerPostIndex, + managedLeanToPostIndex, + managedLeanToPostSide, +} from './assembly' import { resolveConicalLeanToPlacement } from './conical-host' +import { resolveLeanToSlabEdgePlacement } from './placement' import { initializeLeanToExtensionSync } from './system' type RafFn = (callback: (time: number) => void) => number @@ -30,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({ @@ -476,4 +494,164 @@ describe('lean-to scene commit boundary', () => { expect(regularIndexesA).not.toContain(2) expect(regularIndexesB).not.toContain(0) }) + + test('tracks an upper slab edge while retaining one front row of posts', () => { + stopSync() + const building = BuildingNode.parse({ id: 'building_slab_host_sync' }) + const ground = LevelNode.parse({ + id: 'level_slab_host_ground', + parentId: building.id, + level: 0, + height: 3, + }) + const first = LevelNode.parse({ + id: 'level_slab_host_first', + parentId: building.id, + level: 1, + height: 3, + }) + const slab = SlabNode.parse({ + id: 'slab_host_sync', + parentId: first.id, + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + elevation: 0.05, + thickness: 0.2, + }) + const hostNodes = { + [building.id]: building, + [ground.id]: ground, + [first.id]: first, + [slab.id]: slab, + } as Record + const leanTo = resolveLeanToSlabEdgePlacement({ + activeLevelId: ground.id, + edgeIndex: 0, + edgeT: 0.5, + nodes: hostNodes, + slab, + })! + const assembly = createLeanToAssembly(leanTo, undefined, hostNodes) + const nodes = Object.fromEntries( + [ + { ...building, children: [ground.id, first.id] }, + { ...ground, children: [leanTo.id] }, + { ...first, children: [slab.id] }, + slab, + assembly.extension, + ...assembly.children, + ].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [building.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + useScene.getState().updateNode(slab.id as AnyNodeId, { + polygon: [ + [0, 0], + [8, 0], + [8, 4], + [0, 4], + ], + elevation: 0.15, + }) + + const synced = useScene.getState().nodes[leanTo.id as AnyNodeId] + expect(synced?.type).toBe('lean-to-extension') + if (synced?.type !== 'lean-to-extension') return + expect(synced.position).toEqual([4, 0, 0]) + expect(synced.span).toBeCloseTo(7.9, 6) + expect(synced.highEdgeHeight).toBeCloseTo(2.95, 6) + const posts = synced.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter((node) => node?.type === 'column') + expect(posts).toHaveLength(4) + expect( + posts.every((post) => post?.type === 'column' && managedLeanToPostSide(post) === 'low'), + ).toBe(true) + }) + + test('keeps a deleted freestanding pillar omitted while the remaining pillars resize', () => { + stopSync() + const level = LevelNode.parse({ id: 'level_omitted_post', level: 0 }) + const leanTo = LeanToExtensionNode.parse({ + id: 'leanto_omitted_post', + parentId: level.id, + hostKind: 'freestanding', + highSideMode: 'independent-high-beam', + connectionMode: 'manual', + autoSpan: false, + span: 4, + }) + const assembly = createLeanToAssembly(leanTo) + const nodes = Object.fromEntries( + [{ ...level, children: [leanTo.id] }, assembly.extension, ...assembly.children].map( + (node) => [node.id, node], + ), + ) as Record + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + materials: {}, + nodes, + readOnly: false, + rootNodeIds: [level.id], + } as never) + clearSceneHistory() + stopSync = initializeLeanToExtensionSync(createSceneApi(useScene)) + + const deletedPost = assembly.posts.find( + (post) => managedLeanToPostSide(post) === 'high' && managedLeanToPostIndex(post) === 2, + )! + const resizingPost = assembly.posts.find( + (post) => managedLeanToPostSide(post) === 'low' && managedLeanToPostIndex(post) === 2, + )! + const originalResizingX = resizingPost.position[0] + + useScene.getState().deleteNode(deletedPost.id as AnyNodeId) + + const afterDelete = useScene.getState().nodes[leanTo.id as AnyNodeId] + expect(afterDelete?.type).toBe('lean-to-extension') + if (afterDelete?.type !== 'lean-to-extension') return + expect(afterDelete.omittedPostSlots).toEqual([{ side: 'high', index: 2, layoutCount: 3 }]) + expect( + afterDelete.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .some( + (child) => + child?.type === 'column' && + managedLeanToPostSide(child) === 'high' && + managedLeanToPostIndex(child) === 2, + ), + ).toBe(false) + + useScene.getState().updateNode(leanTo.id as AnyNodeId, { span: 8 }) + + const afterResize = useScene.getState().nodes[leanTo.id as AnyNodeId] + expect(afterResize?.type).toBe('lean-to-extension') + if (afterResize?.type !== 'lean-to-extension') return + const posts = afterResize.children + .map((id) => useScene.getState().nodes[id as AnyNodeId]) + .filter((child): child is Extract => child?.type === 'column') + expect(posts).toHaveLength(7) + expect( + posts.some( + (post) => managedLeanToPostSide(post) === 'high' && managedLeanToPostIndex(post) === 3, + ), + ).toBe(false) + expect(posts.find((post) => post.id === resizingPost.id)?.position[0]).not.toBe( + originalResizingX, + ) + }) }) diff --git a/packages/nodes/src/lean-to-extension/system.tsx b/packages/nodes/src/lean-to-extension/system.tsx index dcb1f2da17..dc4f804739 100644 --- a/packages/nodes/src/lean-to-extension/system.tsx +++ b/packages/nodes/src/lean-to-extension/system.tsx @@ -42,7 +42,9 @@ import { resolveLeanToCornerJoints, } from './corner-joint' import { LEAN_TO_EXTENSION_GEOMETRY_REVISION, resolveLeanToSpanArc } from './layout' +import { reconcileLeanToSlabEdgePlacement } from './placement' import { resolveLeanToEndAbutments } from './placement-validation' +import { isLeanToPostOmitted } from './post-omissions' import { applyLeanToAvailableWallSpan, applyLeanToRoofAttachment, @@ -208,9 +210,8 @@ function leanToGroundSignature( nodes: Record, ): number[] { const parent = leanTo.parentId ? nodes[leanTo.parentId as AnyNodeId] : undefined - if (parent?.type !== 'wall') return [] - const wall = parent as WallNode - const cornerJoints = resolveLeanToCornerJoints(leanTo, wall, nodes) + const wall = parent?.type === 'wall' ? (parent as WallNode) : undefined + const cornerJoints = wall ? resolveLeanToCornerJoints(leanTo, wall, nodes) : {} const sides: LeanToPostSide[] = leanTo.highSideMode === 'independent-high-beam' ? ['low', 'high'] : ['low'] const values: number[] = [] @@ -219,8 +220,9 @@ function leanToGroundSignature( values.push(resolveLeanToPostBaseY(leanTo, wall, nodes, index, side)) } } - for (const joint of Object.values(cornerJoints)) { + for (const joint of wall ? Object.values(cornerJoints) : []) { if (!joint?.sharedPostOwner) continue + if (isLeanToPostOmitted(leanTo, 'low', leanToCornerPostIndex(joint.side))) continue const bent = bendLocalPoint(leanTo, joint.sharedPostPosition[0], joint.sharedPostPosition[2]) values.push( resolveLeanToPostBaseYAtLocalPosition(leanTo, wall, nodes, [ @@ -268,6 +270,7 @@ function extensionSignature( leanTo.postLayoutMode, leanTo.postSpacing, leanTo.postInset, + leanTo.omittedPostSlots, leanTo.postBracing, leanTo.footingStyle, leanTo.highSideMode, @@ -320,6 +323,8 @@ function extensionSignature( function attachmentNeedsUpdate(current: LeanToExtensionNode, next: LeanToExtensionNode): boolean { return ( + current.hostKind !== next.hostKind || + current.highSideMode !== next.highSideMode || current.connectionMode !== next.connectionMode || current.hostRoofId !== next.hostRoofId || current.hostRoofSegmentId !== next.hostRoofSegmentId || @@ -335,6 +340,7 @@ function attachmentNeedsUpdate(current: LeanToExtensionNode, next: LeanToExtensi current.spanArcCenterZ !== next.spanArcCenterZ || current.spanArcRadius !== next.spanArcRadius || !sameTuple(current.position, next.position) || + !sameTuple(current.rotation, next.rotation) || current.roofThickness !== next.roofThickness || current.shingleThickness !== next.shingleThickness || JSON.stringify(current.metadata) !== JSON.stringify(next.metadata) @@ -356,8 +362,14 @@ function resolveEffectiveLeanTo( if (parent?.type === 'roof-segment' && leanTo.hostKind === 'conical-roof') { return resolveConicalLeanToPlacement(parent, leanTo) ?? leanTo } + if (leanTo.hostKind === 'slab-edge') { + return reconcileLeanToSlabEdgePlacement(leanTo, nodes) + } if (parent?.type !== 'wall') { - return leanTo.connectionMode === 'manual' ? leanTo : clearLeanToRoofAttachment(leanTo) + const detached = leanTo.connectionMode === 'manual' ? leanTo : clearLeanToRoofAttachment(leanTo) + return leanTo.hostKind === 'freestanding' + ? { ...detached, highSideMode: 'independent-high-beam' } + : detached } const wall = parent as WallNode const wallSpanningLeanTo = applyLeanToWallCornerSpan(applyLeanToWallAutoSpan(leanTo, wall), wall) @@ -465,6 +477,8 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { update.push({ id, data: { + hostKind: effectiveLeanTo.hostKind, + highSideMode: effectiveLeanTo.highSideMode, connectionMode: effectiveLeanTo.connectionMode, hostRoofId: effectiveLeanTo.hostRoofId, hostRoofSegmentId: effectiveLeanTo.hostRoofSegmentId, @@ -480,6 +494,7 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { spanArcCenterZ: effectiveLeanTo.spanArcCenterZ, spanArcRadius: effectiveLeanTo.spanArcRadius, position: effectiveLeanTo.position, + rotation: effectiveLeanTo.rotation, roofThickness: effectiveLeanTo.roofThickness, shingleThickness: effectiveLeanTo.shingleThickness, metadata: effectiveLeanTo.metadata, @@ -583,10 +598,13 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { for (const index of resolveLeanToPostIndexes(effectiveLeanTo, cornerJoints, side)) { const key = `${side}:${index}` desiredPostKeys.add(key) - const postBaseY = - parent?.type === 'wall' - ? resolveLeanToPostBaseY(effectiveLeanTo, parent, nodes, index, side) - : 0 + const postBaseY = resolveLeanToPostBaseY( + effectiveLeanTo, + parent?.type === 'wall' ? parent : undefined, + nodes, + index, + side, + ) const current = managedPosts.get(key) const gutterSetback = side === 'low' ? resolveLeanToPostGutterSetback(effectiveLeanTo, current) : 0 @@ -620,6 +638,7 @@ export function initializeLeanToExtensionSync(sceneApi: SceneApi) { for (const joint of Object.values(cornerJoints)) { if (!joint?.sharedPostOwner) continue const index = leanToCornerPostIndex(joint.side) + if (isLeanToPostOmitted(effectiveLeanTo, 'low', index)) continue const key = `low:${index}` desiredPostKeys.add(key) const bentCornerPost = bendLocalPoint( diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx index 3b97c1c0ee..6fed9aaacb 100644 --- a/packages/nodes/src/lean-to-extension/tool.tsx +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -5,15 +5,18 @@ import { type AnyNodeId, type DoorEvent, emitter, + type GridEvent, getLevelElevations, getWallBaseElevationForNodes, type RoofEvent, type RoofSegmentEvent, + type SlabEvent, sceneRegistry, type WallEvent, type WallNode, } from '@pascal-app/core' import { + isGridSnapActive, triggerSFX, useEditor, useInteractionScope, @@ -21,6 +24,7 @@ import { } 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' @@ -29,6 +33,13 @@ import { resolveLeanToWallPlacement, resolveLeanToWallSurfaceHit, } from './layout' +import { + findLeanToSlabEdgePlacement, + type LeanToPlanPlacementTarget, + nextLeanToPlacementRotation, + resolveLeanToCommitTarget, + resolveLeanToPlanPlacement, +} from './placement' import { isLeanToHostOnLevel } from './placement-scope' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' import LeanToExtensionPreview from './preview' @@ -50,6 +61,12 @@ type PreviewPose = { valid: boolean } +type PlacementCommitTarget = { + node: LeanToExtensionNode + parentId: AnyNodeId + valid: boolean +} + const LeanToExtensionTool = () => { const { activeLevelId, sceneApi, selectNode } = useRegistryToolContext() const viewMode = useEditor((state) => state.viewMode) @@ -58,6 +75,11 @@ const LeanToExtensionTool = () => { useEffect(() => { if (!(activeLevelId && viewMode === '3d')) return useInteractionScope.getState().begin({ kind: 'drafting', tool: 'lean-to-extension' }) + let lastMeshEventTime = -1 + let freestandingRotationY = 0 + let lastFreestandingEvent: GridEvent | SlabEvent | null = null + let lastPreviewTarget: PlacementCommitTarget | null = null + let commitQueued = false const resolveBaseY = (wall: WallNode) => { const nodes = sceneApi.nodes() as Record @@ -66,15 +88,22 @@ const LeanToExtensionTool = () => { } 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?.([ + sceneApi.createMany([ { node: assembly.extension, parentId }, ...assembly.children.map((child) => ({ node: child, parentId: (child.parentId as AnyNodeId | null) ?? undefined, })), ]) + lastPreviewTarget = null + setPreview(null) selectNode(assembly.extension.id as AnyNodeId) triggerSFX('sfx:structure-build') if (useEditor.getState().getContinuation('point') !== 'repeat') { @@ -102,28 +131,104 @@ const LeanToExtensionTool = () => { } } + const levelPreviewPose = (node: LeanToExtensionNode): PreviewPose => { + const levelObject = sceneRegistry.nodes.get(activeLevelId) + if (!levelObject) { + return { + node, + position: node.position, + rotationY: node.rotation[1], + valid: true, + } + } + const position = levelObject.localToWorld(new Vector3(...node.position)) + const rotationY = + new Euler().setFromQuaternion(levelObject.getWorldQuaternion(new Quaternion()), 'YXZ').y + + node.rotation[1] + return { node, position: [position.x, position.y, position.z], rotationY, valid: true } + } + + const updateFreeTarget = (event: GridEvent | SlabEvent) => { + const step = + !event.nativeEvent.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) + const nodes = sceneApi.nodes() as Record + const target = resolveLeanToPlanPlacement({ + activeLevelId, + freestandingPoint: [snap(event.localPosition[0]), snap(event.localPosition[2])], + freestandingRotationY, + nodes, + point: [event.localPosition[0], event.localPosition[2]], + }) + lastPreviewTarget = target.node.parentId + ? { + node: target.node, + parentId: target.node.parentId as AnyNodeId, + valid: target.valid, + } + : null + lastFreestandingEvent = target.node.hostKind === 'freestanding' ? event : null + if (target.wall) { + const pose = leanToWallLocalPose(target.wall, target.node, resolveBaseY(target.wall)) + setPreview((current) => ({ + node: + current && + leanToExtensionGeometryKey(current.node) === leanToExtensionGeometryKey(target.node) + ? current.node + : target.node, + ...pose, + valid: target.valid, + })) + } else { + setPreview({ ...levelPreviewPose(target.node), valid: target.valid }) + } + return target + } + + const updateSlabTarget = (event: SlabEvent): LeanToPlanPlacementTarget => { + const nodes = sceneApi.nodes() as Record + const node = findLeanToSlabEdgePlacement( + [event.localPosition[0], event.localPosition[2]], + nodes, + activeLevelId, + ) + if (!node || node.hostSlabId !== event.node.id) return updateFreeTarget(event) + lastFreestandingEvent = null + lastPreviewTarget = node.parentId + ? { node, parentId: node.parentId as AnyNodeId, valid: true } + : null + setPreview(levelPreviewPose(node)) + return { node, valid: true } + } + const updateConicalSegmentTarget = (event: RoofSegmentEvent) => { + lastFreestandingEvent = null const nodes = sceneApi.nodes() as Record if (!isLeanToHostOnLevel(event.node, nodes, activeLevelId)) { + lastPreviewTarget = null setPreview(null) return null } const node = resolveConicalLeanToSurfaceHit(event.node, event.localPosition, event.normal) if (!node) { + lastPreviewTarget = null setPreview(null) return null } const valid = !isConicalLeanToHostOccupied(event.node.id, nodes) + lastPreviewTarget = { node, parentId: event.node.id as AnyNodeId, valid } setPreview(worldPreviewPose(event, node, node.position, 0, valid)) return valid ? node : null } const updateConicalRoofTarget = (event: RoofEvent) => { + lastFreestandingEvent = null const nodes = sceneApi.nodes() as Record if ( !isLeanToHostOnLevel(event.node, nodes, activeLevelId) || event.object.name !== 'merged-roof' ) { + lastPreviewTarget = null setPreview(null) return null } @@ -150,6 +255,7 @@ const LeanToExtensionTool = () => { 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( @@ -163,23 +269,28 @@ const LeanToExtensionTool = () => { ) return valid ? node : null } + lastPreviewTarget = null setPreview(null) return null } const updateTarget = (event: WallEvent) => { + lastFreestandingEvent = null const nodes = sceneApi.nodes() as Record if (!isLeanToHostOnLevel(event.node, nodes, activeLevelId)) { + lastPreviewTarget = null setPreview(null) return null } const hit = resolveLeanToWallSurfaceHit(event.node, event.localPosition, event.normal) if (!hit) { + lastPreviewTarget = null setPreview(null) return null } const wallPlacement = resolveLeanToWallPlacement(event.node, hit.localX, hit.side) if (!wallPlacement) { + lastPreviewTarget = null setPreview(null) return null } @@ -196,6 +307,7 @@ const LeanToExtensionTool = () => { const node = resolveLeanToEndAbutments(attachedNode, event.node, nodes) const valid = leanToPlacementConflicts(node, event.node, nodes).length === 0 const pose = leanToWallLocalPose(event.node, node, resolveBaseY(event.node)) + lastPreviewTarget = { node, parentId: event.node.id as AnyNodeId, valid } setPreview((current) => ({ node: current && leanToExtensionGeometryKey(current.node) === leanToExtensionGeometryKey(node) @@ -208,23 +320,31 @@ const LeanToExtensionTool = () => { } 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() - commitNode(node, event.node.id as AnyNodeId) + lastMeshEventTime = event.nativeEvent.timeStamp + const visibleTarget = lastPreviewTarget + updateTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!target?.valid) return + stopPlacementCommitPropagation(event) + commitNode(target.node, target.parentId) } const onDoorMove = (event: DoorEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp const wallId = event.node.wallId ?? event.node.parentId const wall = wallId ? sceneApi.get(wallId as AnyNodeId) : undefined const wallObject = wall ? sceneRegistry.nodes.get(wall.id) : undefined if (!(wall?.type === 'wall' && wallObject)) { + lastPreviewTarget = null setPreview(null) return } @@ -232,41 +352,99 @@ const LeanToExtensionTool = () => { } 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) - const node = updateTarget(target) - if (!node) return - event.stopPropagation() - commitNode(node, wall.id as AnyNodeId) + updateTarget(target) + const commitTarget = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!commitTarget?.valid) return + stopPlacementCommitPropagation(event) + commitNode(commitTarget.node, commitTarget.parentId) } const onDoorLeave = () => { + lastPreviewTarget = null setPreview(null) } const onRoofSegmentMove = (event: RoofSegmentEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp updateConicalSegmentTarget(event) } const onRoofSegmentClick = (event: RoofSegmentEvent) => { - const node = updateConicalSegmentTarget(event) - if (!node) return - event.stopPropagation() - commitNode(node, event.node.id as AnyNodeId) + lastMeshEventTime = event.nativeEvent.timeStamp + const visibleTarget = lastPreviewTarget + updateConicalSegmentTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!target?.valid) return + stopPlacementCommitPropagation(event) + commitNode(target.node, target.parentId) } const onRoofMove = (event: RoofEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp updateConicalRoofTarget(event) } const onRoofClick = (event: RoofEvent) => { - const node = updateConicalRoofTarget(event) - if (!node) return - const segment = node.parentId ? sceneApi.get(node.parentId as AnyNodeId) : undefined - if (segment?.type !== 'roof-segment') return - event.stopPropagation() - commitNode(node, segment.id as AnyNodeId) + lastMeshEventTime = event.nativeEvent.timeStamp + const visibleTarget = lastPreviewTarget + updateConicalRoofTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!target?.valid) return + stopPlacementCommitPropagation(event) + commitNode(target.node, target.parentId) + } + const onSlabMove = (event: SlabEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + updateSlabTarget(event) + } + const onSlabClick = (event: SlabEvent) => { + lastMeshEventTime = event.nativeEvent.timeStamp + const visibleTarget = lastPreviewTarget + updateSlabTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + stopPlacementCommitPropagation(event) + if (!target?.valid) return + commitNode(target.node, target.parentId) + } + const onGridMove = (event: GridEvent) => { + if (event.nativeEvent.timeStamp === lastMeshEventTime) return + updateFreeTarget(event) + } + const onGridClick = (event: GridEvent) => { + if (event.nativeEvent.timeStamp === lastMeshEventTime) return + const visibleTarget = lastPreviewTarget + updateFreeTarget(event) + const target = resolveLeanToCommitTarget(visibleTarget, lastPreviewTarget) + if (!target?.valid) return + commitNode(target.node, target.parentId) + } + + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return + } + if (!lastFreestandingEvent) return + + const nextRotation = nextLeanToPlacementRotation( + freestandingRotationY, + event.key, + event.metaKey || event.ctrlKey, + ) + if (nextRotation === freestandingRotationY) return + + event.preventDefault() + freestandingRotationY = nextRotation + triggerSFX('sfx:item-rotate') + updateFreeTarget(lastFreestandingEvent) } emitter.on('wall:move', onWallMove) @@ -285,6 +463,13 @@ const LeanToExtensionTool = () => { emitter.on('roof:enter', onRoofMove) emitter.on('roof:leave', onWallLeave) emitter.on('roof:click', onRoofClick) + emitter.on('slab:move', onSlabMove) + emitter.on('slab:enter', onSlabMove) + emitter.on('slab:leave', onWallLeave) + emitter.on('slab:click', onSlabClick) + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + window.addEventListener('keydown', onKeyDown) return () => { emitter.off('wall:move', onWallMove) emitter.off('wall:enter', onWallMove) @@ -302,6 +487,13 @@ const LeanToExtensionTool = () => { emitter.off('roof:enter', onRoofMove) emitter.off('roof:leave', onWallLeave) emitter.off('roof:click', onRoofClick) + emitter.off('slab:move', onSlabMove) + emitter.off('slab:enter', onSlabMove) + emitter.off('slab:leave', onWallLeave) + emitter.off('slab:click', onSlabClick) + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + window.removeEventListener('keydown', onKeyDown) setPreview(null) useInteractionScope .getState() diff --git a/packages/nodes/src/roof-segment/definition.test.ts b/packages/nodes/src/roof-segment/definition.test.ts index d25bd52e23..4d93e6046a 100644 --- a/packages/nodes/src/roof-segment/definition.test.ts +++ b/packages/nodes/src/roof-segment/definition.test.ts @@ -120,29 +120,15 @@ describe('roof-segment resize handles', () => { expect(backPatch).toMatchObject({ depth: 8, position: [10, 0, 19] }) }) - test('hides the pitch handle for managed lean-to roof segments', () => { + test('hides the pitch handle for parent-managed roof segments', () => { const handle = pitchHandle() - const managed = segment({ - metadata: { - managedByLeanTo: 'lean_to_test', - leanToRole: 'roof-segment', - }, - }) + const managed = segment({ managedByParent: true }) expect(handle.visible?.(segment(), undefined as never)).not.toBe(false) expect(handle.visible?.(managed, undefined as never)).toBe(false) }) - test('hides all direct handles for managed lean-to roof segments', () => { - expect( - handles( - segment({ - metadata: { - managedByLeanTo: 'lean_to_test', - leanToRole: 'roof-segment', - }, - }), - ), - ).toEqual([]) + test('hides all direct handles for parent-managed roof segments', () => { + expect(handles(segment({ managedByParent: true }))).toEqual([]) }) }) diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 7cfe8d1734..e1c7eb2524 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -37,13 +37,6 @@ function getPeakHeight(n: RoofSegmentNodeType): number { return n.wallHeight + getActiveRoofHeight(n) } -function isManagedLeanToRoofSegment(n: RoofSegmentNodeType): boolean { - const metadata = n.metadata - if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return false - const record = metadata as Record - return record.managedByLeanTo !== undefined && record.leanToRole === 'roof-segment' -} - function getSideResizeHandleY(n: RoofSegmentNodeType, localZ: number): number { if (n.roofType !== 'shed') return Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2 @@ -237,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({ @@ -309,7 +302,7 @@ const conicalRoofSegmentHandles: HandleDescriptor[] = [ function resolveRoofSegmentHandles( node: RoofSegmentNodeType, ): HandleDescriptor[] { - if (isManagedLeanToRoofSegment(node)) return [] + if (node.managedByParent) return [] return node.roofType === 'conical' ? conicalRoofSegmentHandles : roofSegmentHandles } @@ -321,7 +314,7 @@ function resolveRoofSegmentHandles( */ export const roofSegmentDefinition: NodeDefinition = { kind: 'roof-segment', - schemaVersion: 1, + schemaVersion: 2, schema: RoofSegmentNode, category: 'structure', surfaceRole: 'roof', diff --git a/packages/nodes/src/roof/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/shared/dormer-wall-opening-placement.test.ts b/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts index b8d58e8206..d06bcbba58 100644 --- a/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts +++ b/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts @@ -1,6 +1,15 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNode, type DormerEvent, DormerNode, WindowNode } from '@pascal-app/core' import { + type AnyNode, + type DormerEvent, + DormerNode, + type WindowEvent, + WindowNode, +} from '@pascal-app/core' +import { Object3D } from 'three' +import { + dormerEventFromHostedWindow, + getDormerWindowWorldYaw, resolveDormerWindowTarget, shouldWriteDormerWindowPreviewHost, } from './dormer-wall-opening-placement' @@ -17,6 +26,37 @@ function event( } 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({ @@ -87,6 +127,115 @@ describe('resolveDormerWindowTarget', () => { 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('shouldWriteDormerWindowPreviewHost', () => { diff --git a/packages/nodes/src/shared/dormer-wall-opening-placement.ts b/packages/nodes/src/shared/dormer-wall-opening-placement.ts index 5b544987b6..70a9439d69 100644 --- a/packages/nodes/src/shared/dormer-wall-opening-placement.ts +++ b/packages/nodes/src/shared/dormer-wall-opening-placement.ts @@ -2,10 +2,13 @@ import { type AnyNode, type DormerEvent, type DormerNode, + dormerPointToWallFace, getDormerWallFaceFrame, - getDormerWallVerticalBounds, + getDormerWallOpeningVerticalBounds, + type WindowEvent, type WindowNode, } from '@pascal-app/core' +import { type Object3D, Vector3 } from 'three' export type DormerWindowTarget = { dormer: DormerNode @@ -14,6 +17,46 @@ export type DormerWindowTarget = { 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 frame = getDormerWallFaceFrame(event.node, target.face) + event.object.updateWorldMatrix(true, false) + dormerFaceNormal + .set(Math.sin(frame.yaw), 0, Math.cos(frame.yaw)) + .transformDirection(event.object.matrixWorld) + return Math.atan2(dormerFaceNormal.x, dormerFaceNormal.z) +} + export function shouldWriteDormerWindowPreviewHost( node: WindowNode, target: DormerWindowTarget, @@ -51,21 +94,6 @@ function faceFromPoint( ).face } -function toFaceLocalPoint( - dormer: DormerNode, - face: DormerWindowTarget['face'], - point: [number, number, number], -): [number, number, number] { - const frame = getDormerWallFaceFrame(dormer, face) - const dx = point[0] - frame.origin[0] - const dz = point[2] - frame.origin[2] - return [ - Math.cos(frame.yaw) * dx + Math.sin(frame.yaw) * dz, - point[1], - -Math.sin(frame.yaw) * dx + Math.cos(frame.yaw) * dz, - ] -} - function hasWindowOverlap( dormer: DormerNode, nodes: Readonly>, @@ -101,22 +129,23 @@ export function resolveDormerWindowTarget(args: { height: number nodes: Readonly> ignoreId?: string + snap?: (value: number) => number }): DormerWindowTarget | null { - const { event, width, height, nodes, ignoreId } = args + const { event, width, height, nodes, ignoreId, snap = (value) => value } = args const face = faceFromNormal(event.normal) ?? faceFromPoint(event.node, event.localPosition) - const point = toFaceLocalPoint(event.node, face, event.localPosition) + const point = dormerPointToWallFace(event.node, face, event.localPosition) const frame = getDormerWallFaceFrame(event.node, face) - const vertical = getDormerWallVerticalBounds(event.node) const clampedX = Math.max( - width / 2, - Math.min(frame.width - width / 2, point[0] + frame.width / 2), + -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, point[1])) - const position: [number, number, number] = [clampedX - frame.width / 2, clampedY, 0] + const clampedY = Math.max(minY, Math.min(maxY, snap(point[1]))) + const position: [number, number, number] = [clampedX, clampedY, 0] return { dormer: event.node, 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 c95c7bd284..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: { diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 34f22ab2b0..744f9585be 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -1,9 +1,9 @@ import { type AnyNodeId, type DormerEvent, + dormerWallFacePointToDormer, emitter, type GridEvent, - getDormerWallFaceFrame, holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, @@ -13,6 +13,7 @@ import { useLiveTransforms, useScene, type WallEvent, + type WindowEvent, WindowNode, } from '@pascal-app/core' import { @@ -31,13 +32,15 @@ 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, + getDormerWindowWorldYaw, resolveDormerWindowTarget, shouldWriteDormerWindowPreviewHost, } from '../shared/dormer-wall-opening-placement' @@ -92,6 +95,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 @@ -271,7 +275,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 @@ -652,7 +656,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode triggerSFX('sfx:structure-build') hideCursor() - useViewer.getState().setSelection({ selectedIds: [placedId] }) + selectNode(placedId as AnyNodeId) exitMoveMode() } @@ -761,7 +765,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 @@ -778,14 +782,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode height: movingWindowNode.height, ignoreId: movingWindowNode.id, nodes: useScene.getState().nodes, + snap: snapToHalf, }) const dormerWindowWorldPosition = (event: DormerEvent, target: DormerWindowTarget) => { - const frame = getDormerWallFaceFrame(event.node, target.face) const point = new Vector3( - frame.origin[0] + target.position[0] * Math.cos(frame.yaw), - target.position[1], - frame.origin[2] + target.position[0] * Math.sin(frame.yaw), + ...dormerWallFacePointToDormer(event.node, target.face, target.position), ) event.object.localToWorld(point) return [point.x, point.y, point.z] as [number, number, number] @@ -825,7 +827,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } setGhostPose({ position: dormerWindowWorldPosition(event, target), - rotationY: target.face === 'front' ? 0 : Math.PI, + rotationY: getDormerWindowWorldYaw(event, target), tint: target.valid || altHeld ? 'valid' : 'invalid', floorY: dormerWindowWorldPosition(event, target)[1], side, @@ -902,7 +904,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useLiveTransforms.getState().clear(movingWindowNode.id) triggerSFX('sfx:structure-build') hideCursor() - useViewer.getState().setSelection({ selectedIds: [placedId] }) + selectNode(placedId as AnyNodeId) exitMoveMode() event.stopPropagation() } @@ -936,6 +938,33 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode 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 @@ -1083,7 +1112,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() } @@ -1207,6 +1236,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode 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) @@ -1305,6 +1338,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode 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) @@ -1312,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/tool.tsx b/packages/nodes/src/window/tool.tsx index 9d6cf9395d..e9da161545 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -3,9 +3,9 @@ import { type AnyNodeId, type DormerEvent, type DormerNode, + dormerWallFacePointToDormer, emitter, type GridEvent, - getDormerWallFaceFrame, holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, @@ -32,13 +32,15 @@ import { 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, + getDormerWindowWorldYaw, resolveDormerWindowTarget, } from '../shared/dormer-wall-opening-placement' import { @@ -98,6 +100,7 @@ type HostKind = 'wall' | 'roof' | 'dormer' | 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!) @@ -176,7 +179,7 @@ const WindowTool: React.FC = () => { // 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 @@ -301,12 +304,8 @@ const WindowTool: React.FC = () => { } const dormerWindowWorldPosition = (event: DormerEvent, target: DormerWindowTarget) => { - const frame = getDormerWallFaceFrame(event.node, target.face) - const [u, v] = target.position const point = roofFallbackPoint.set( - frame.origin[0] + u * Math.cos(frame.yaw), - v, - frame.origin[2] + u * Math.sin(frame.yaw), + ...dormerWallFacePointToDormer(event.node, target.face, target.position), ) event.object.localToWorld(point) return worldToSelectedBuildingLocal(point) @@ -347,7 +346,7 @@ const WindowTool: React.FC = () => { clearOpeningGuides3D() updateCursor( dormerWindowWorldPosition(event, target), - target.face === 'front' ? 0 : Math.PI, + getDormerWindowWorldYaw(event, target), target.valid, 0, ) @@ -563,7 +562,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() @@ -625,7 +624,7 @@ const WindowTool: React.FC = () => { state.createNode(node, dormer.id as AnyNodeId) state.dirtyNodes.add(dormer.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() @@ -712,7 +711,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. @@ -737,6 +736,7 @@ const WindowTool: React.FC = () => { height: draftRef.current?.height ?? FALLBACK_HEIGHT, nodes: useScene.getState().nodes, ignoreId: draftRef.current?.id, + snap: snapToHalf, }) const showDormerFallbackCursor = (event: DormerEvent) => { @@ -786,29 +786,7 @@ const WindowTool: React.FC = () => { : undefined const object = dormer ? sceneRegistry.nodes.get(dormer.id as AnyNodeId) : undefined if (!(dormer?.type === 'dormer' && object)) return null - - 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, - } + return dormerEventFromHostedWindow(event, dormer, object) } const onDormerWindowHover = (event: WindowEvent) => { @@ -945,7 +923,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() @@ -1044,7 +1022,7 @@ const WindowTool: React.FC = () => { 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.test.ts b/packages/viewer/src/systems/roof/roof-system.test.ts index 6dcde3f2bc..d8dd7e650a 100644 --- a/packages/viewer/src/systems/roof/roof-system.test.ts +++ b/packages/viewer/src/systems/roof/roof-system.test.ts @@ -176,7 +176,8 @@ describe('roof system shed geometry', () => { shedSideInfillSpan: span, shedSideInfillMinX: -infillHalfWidth, shedSideInfillMaxX: infillHalfWidth, - metadata: { managedByLeanTo: 'lean_to_test', leanToRole: 'roof-segment' }, + shedInsetEndPanels: true, + wallShell: 'omit', }) const { geometry, roofSideX, sideInfillNormals, sideInfillX } = inspectShedGeometry(segment) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 8fa6c6e0d1..c42151709d 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1037,16 +1037,10 @@ function readShedOpenEndSides(node: RoofSegmentNode): Set { return new Set(value.filter((side): side is ShedEndSide => side === 'left' || side === 'right')) } -function isManagedLeanToRoofSegment(node: Pick): boolean { - const metadata = node.metadata - if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return false - const record = metadata as Record - return record.managedByLeanTo !== undefined && record.leanToRole === 'roof-segment' -} - 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 - if (isManagedLeanToRoofSegment(node)) return false // Older composite roofs use overlapping shed segments as deck pieces. Their // wall volumes were never part of the rendered shell and make CSG grow @@ -2997,7 +2991,7 @@ function addShedInsetEndPanels( applySegmentTransform: boolean, ): THREE.BufferGeometry { const shedSegments = segments.filter( - (segment) => segment.roofType === 'shed' && isManagedLeanToRoofSegment(segment), + (segment) => segment.roofType === 'shed' && segment.shedInsetEndPanels, ) if (shedSegments.length === 0) return geometry From dc6b4a9b3f745cb916649b105985e0efe17700f4 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 25 Aug 2026 15:04:06 +0530 Subject: [PATCH 7/9] Fix centered lean-to placement --- .../tools/shared/pointer-support-cap.test.ts | 47 ++++++- .../tools/shared/pointer-support-cap.ts | 14 +- .../src/lean-to-extension/corner-joint.ts | 28 +++- .../floorplan-affordances.ts | 18 ++- .../lean-to-extension/floorplan-move.test.ts | 2 +- .../src/lean-to-extension/floorplan-move.ts | 4 +- .../nodes/src/lean-to-extension/floorplan.ts | 5 +- .../nodes/src/lean-to-extension/layout.ts | 39 ++--- .../nodes/src/lean-to-extension/move-tool.tsx | 7 +- .../src/lean-to-extension/placement.test.ts | 111 ++++++++++++++- .../nodes/src/lean-to-extension/placement.ts | 98 ++++++++++--- .../src/lean-to-extension/roof-corner.test.ts | 55 ++++++++ packages/nodes/src/lean-to-extension/tool.tsx | 50 +++---- .../nodes/src/shared/wall-attach-target.ts | 133 ++++++++++++++++++ 14 files changed, 514 insertions(+), 97 deletions(-) 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/nodes/src/lean-to-extension/corner-joint.ts b/packages/nodes/src/lean-to-extension/corner-joint.ts index 6138717988..0c1e6017b8 100644 --- a/packages/nodes/src/lean-to-extension/corner-joint.ts +++ b/packages/nodes/src/lean-to-extension/corner-joint.ts @@ -37,6 +37,16 @@ function planDistance(a: readonly [number, number], b: readonly [number, number] return Math.hypot(a[0] - b[0], a[1] - b[1]) } +function directionsFormSupportedCorner( + away: LeanToPlanPoint | null, + candidateAway: LeanToPlanPoint | null, +): boolean { + if (!(away && candidateAway)) return false + const dot = Math.max(-1, Math.min(1, away[0] * candidateAway[0] + away[1] * candidateAway[1])) + const angle = Math.acos(dot) + return angle >= MIN_CORNER_ANGLE - PLAN_TOLERANCE && angle <= MAX_CORNER_ANGLE + PLAN_TOLERANCE +} + function wallFrame(wall: WallNode) { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] @@ -244,12 +254,18 @@ function isSupportedHostCorner( candidate: LeanToExtensionNode, candidateSide: LeanToCornerSide, ): boolean { - const away = awayFromEndChordDirection(wall, leanTo, side) - const candidateAway = awayFromEndChordDirection(candidateWall, candidate, candidateSide) - if (!(away && candidateAway)) return false - const dot = Math.max(-1, Math.min(1, away[0] * candidateAway[0] + away[1] * candidateAway[1])) - const angle = Math.acos(dot) - return angle >= MIN_CORNER_ANGLE - PLAN_TOLERANCE && angle <= MAX_CORNER_ANGLE + PLAN_TOLERANCE + if ( + directionsFormSupportedCorner( + awayFromEndDirection(wall, leanTo, side), + awayFromEndDirection(candidateWall, candidate, candidateSide), + ) + ) { + return true + } + return directionsFormSupportedCorner( + awayFromEndChordDirection(wall, leanTo, side), + awayFromEndChordDirection(candidateWall, candidate, candidateSide), + ) } function leanToPointToWorld( diff --git a/packages/nodes/src/lean-to-extension/floorplan-affordances.ts b/packages/nodes/src/lean-to-extension/floorplan-affordances.ts index 84b05360fd..0ba44ac798 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-affordances.ts +++ b/packages/nodes/src/lean-to-extension/floorplan-affordances.ts @@ -12,7 +12,11 @@ import { } from '@pascal-app/core' import { getSegmentGridStep, isAngleSnapActive } from '@pascal-app/editor' import { rotateAffordanceDelta } from '../shared/rotate-affordance' -import { resolveLeanToEdgeSnapTargets, resolveLeanToSpanResizeProposal } from './layout' +import { + resolveLeanToEdgeSnapTargets, + resolveLeanToPlanCenter, + resolveLeanToSpanResizeProposal, +} from './layout' import { deriveLeanToResizePatch } from './parametrics' import { moveLeanToAlongSlabEdge } from './placement' @@ -125,16 +129,22 @@ export const leanToRotateAffordance: FloorplanAffordance = 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] - node.position[2], - initialPlanPoint[0] - node.position[0], + initialPlanPoint[1] - center[1], + initialPlanPoint[0] - center[0], ) let lastRotation = node.rotation[1] return { affectedIds: [nodeId], apply({ planPoint }) { const delta = rotateAffordanceDelta({ - center: [node.position[0], node.position[2]], + center, initialAngle, planPoint, free: !isAngleSnapActive(), diff --git a/packages/nodes/src/lean-to-extension/floorplan-move.test.ts b/packages/nodes/src/lean-to-extension/floorplan-move.test.ts index 1d3064bcd3..7db3060a4a 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-move.test.ts +++ b/packages/nodes/src/lean-to-extension/floorplan-move.test.ts @@ -42,7 +42,7 @@ describe('lean-to floorplan move snapping', () => { session.apply({ planPoint: [3.8, 2.2], modifiers: { altKey: true, shiftKey: false } }) expect(useLiveNodeOverrides.getState().overrides.get(moving.id)?.position).toEqual([ - 3.8, 0, 2.2, + 3.8, 0, 0.8250000000000002, ]) expect(session.canCommit()).toBe(true) }) diff --git a/packages/nodes/src/lean-to-extension/floorplan-move.ts b/packages/nodes/src/lean-to-extension/floorplan-move.ts index d9d5019f9b..98856136ba 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-move.ts +++ b/packages/nodes/src/lean-to-extension/floorplan-move.ts @@ -11,7 +11,7 @@ import { import { getSegmentGridStep, isGridSnapActive } from '@pascal-app/editor' import { resolveLeanToEdgeSnapTargets, resolveLeanToMoveProposal } from './layout' import { leanToManagedPreviewOverrides } from './managed-preview' -import { moveLeanToAlongSlabEdge } from './placement' +import { moveLeanToAlongSlabEdge, resolveLeanToPlanPosition } from './placement' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' // Arc-length along the wall centerline to the point on it nearest the @@ -67,7 +67,7 @@ export const leanToFloorplanMoveTarget: FloorplanMoveTarget const step = !modifiers.altKey && isGridSnapActive() ? getSegmentGridStep() : 0 const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) const patch: Partial = { - position: [snap(planPoint[0]), node.position[1], snap(planPoint[1])], + position: resolveLeanToPlanPosition(node, [snap(planPoint[0]), snap(planPoint[1])]), } const previewEntries: ReadonlyArray]> = [ [nodeId, patch as Partial], diff --git a/packages/nodes/src/lean-to-extension/floorplan.ts b/packages/nodes/src/lean-to-extension/floorplan.ts index 5beb20f00c..bbe4e34ef6 100644 --- a/packages/nodes/src/lean-to-extension/floorplan.ts +++ b/packages/nodes/src/lean-to-extension/floorplan.ts @@ -192,12 +192,13 @@ function buildLevelLeanToFloorplan( }) 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] - node.position[2], point[0] - node.position[0]), + angle: Math.atan2(point[1] - center[1], point[0] - center[0]), affordance: 'lean-to-rotate', - pivot: [node.position[0], node.position[2]], + pivot: center, }) } } diff --git a/packages/nodes/src/lean-to-extension/layout.ts b/packages/nodes/src/lean-to-extension/layout.ts index 0d3534bc03..d39ba6b42f 100644 --- a/packages/nodes/src/lean-to-extension/layout.ts +++ b/packages/nodes/src/lean-to-extension/layout.ts @@ -10,6 +10,7 @@ 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' @@ -61,27 +62,21 @@ export function resolveLeanToWallSurfaceHit( if (!normal) return null if (!isCurvedWall(wall)) { if (Math.abs(normal[2]) <= 0.7) return null - return { localX: localPosition[0], side: normal[2] >= 0 ? 'front' : 'back' } + } else if (Math.abs(normal[1]) > 0.7) { + return null } - if (Math.abs(normal[1]) > 0.7) return null - const arc = getWallArcData(wall) - if (!arc) return null const chord = getWallChordFrame(wall) - const point = { - x: chord.start.x + chord.tangent.x * localPosition[0] + chord.normal.x * localPosition[2], - y: chord.start.y + chord.tangent.y * localPosition[0] + chord.normal.y * localPosition[2], - } - const angle = Math.atan2(point.y - arc.center.y, point.x - arc.center.x) - let directedAngle = (angle - arc.startAngle) * arc.direction - while (directedAngle < 0) directedAngle += Math.PI * 2 - const t = Math.max(0, Math.min(1, directedAngle / Math.abs(arc.delta))) - const frame = getWallCurveFrameAt(wall, t) - const signedOffset = - (point.x - frame.point.x) * frame.normal.x + (point.y - frame.point.y) * frame.normal.y + if (chord.length <= 1e-6) return null + const point: [number, number] = [ + chord.start.x + chord.tangent.x * localPosition[0] + chord.normal.x * localPosition[2], + chord.start.y + chord.tangent.y * localPosition[0] + chord.normal.y * localPosition[2], + ] + const attachment = resolveWallAttachmentAtPlanPoint(wall, point) + if (!attachment) return null return { - localX: getWallCurveLength(wall) * t, - side: signedOffset >= 0 ? 'front' : 'back', + localX: attachment.localX, + side: attachment.side, } } @@ -191,6 +186,16 @@ export function resolveLeanToLayout(node: LeanToExtensionNode): LeanToLayout { } } +/** + * Plan-space center of the rendered lean-to footprint, measured from the node + * origin. Placement tools use this shared offset so the pointer marks the + * center of the whole footprint rather than the high-edge origin. + */ +export function resolveLeanToPlanCenter(node: LeanToExtensionNode): [number, number] { + const layout = resolveLeanToLayout(node) + return [layout.roofCenterX, layout.roofCenterZ] +} + // The host wall's true circular arc expressed in the lean-to's local frame. The // anchor frame is sampled at the lean-to's along-wall position (the span center), // so the arc center lies on the local Z axis (local X = 0): `centerZ` is its local diff --git a/packages/nodes/src/lean-to-extension/move-tool.tsx b/packages/nodes/src/lean-to-extension/move-tool.tsx index 9b10e28551..cb5b03e48a 100644 --- a/packages/nodes/src/lean-to-extension/move-tool.tsx +++ b/packages/nodes/src/lean-to-extension/move-tool.tsx @@ -22,7 +22,7 @@ import { resolveLeanToEdgeSnapTargets, resolveLeanToMoveProposal, } from './layout' -import { moveLeanToAlongSlabEdge } from './placement' +import { moveLeanToAlongSlabEdge, resolveLeanToPlanPosition } from './placement' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' import LeanToExtensionPreview from './preview' @@ -225,7 +225,10 @@ const MoveLeanToExtensionTool = ({ node, sceneApi }: MoveLeanToExtensionProps) = !event.nativeEvent.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value) return publishPatch({ - position: [snap(event.localPosition[0]), node.position[1], snap(event.localPosition[2])], + position: resolveLeanToPlanPosition(node, [ + snap(event.localPosition[0]), + snap(event.localPosition[2]), + ]), }) } const onMove = (event: GridEvent) => { diff --git a/packages/nodes/src/lean-to-extension/placement.test.ts b/packages/nodes/src/lean-to-extension/placement.test.ts index 0160d27778..7a4471893e 100644 --- a/packages/nodes/src/lean-to-extension/placement.test.ts +++ b/packages/nodes/src/lean-to-extension/placement.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNode, BuildingNode, LevelNode, SlabNode, WallNode } from '@pascal-app/core' +import { + type AnyNode, + BuildingNode, + getWallCurveLength, + LevelNode, + SlabNode, + WallNode, +} from '@pascal-app/core' +import { readLeanToCornerJointMetadata } from './corner-joint' +import { resolveLeanToLayout, resolveLeanToWallPlacement } from './layout' import { findLeanToSlabEdgePlacement, nextLeanToPlacementRotation, @@ -8,7 +17,9 @@ import { resolveLeanToFreestandingPlacement, resolveLeanToPlanPlacement, resolveLeanToSlabEdgePlacement, + resolveLeanToWallPlanTarget, } from './placement' +import { applyLeanToWallAutoSpan } from './roof-attachment' describe('lean-to canopy placement', () => { test('places a freestanding canopy on the active level with two supported sides', () => { @@ -19,7 +30,7 @@ describe('lean-to canopy placement', () => { hostKind: 'freestanding', highSideMode: 'independent-high-beam', connectionMode: 'manual', - position: [4, 0, 6], + position: [4, 0, 4.625], rotation: [0, 0, 0], }) expect(node.hostRoofId).toBeUndefined() @@ -40,6 +51,22 @@ describe('lean-to canopy placement', () => { }) }) + 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) @@ -91,6 +118,86 @@ describe('lean-to canopy placement', () => { }) }) + 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({ diff --git a/packages/nodes/src/lean-to-extension/placement.ts b/packages/nodes/src/lean-to-extension/placement.ts index 93088b0c59..e7aaa8b301 100644 --- a/packages/nodes/src/lean-to-extension/placement.ts +++ b/packages/nodes/src/lean-to-extension/placement.ts @@ -6,8 +6,13 @@ import { type SlabNode, type WallNode, } from '@pascal-app/core' -import { findClosestWallInPlan } from '../shared/wall-attach-target' -import { leanToLowEdgeHeight, resolveLeanToWallPlacement } from './layout' +import { findClosestWallAttachmentInPlan } from '../shared/wall-attach-target' +import { + LEAN_TO_CORNER_JOINTS_KEY, + leanToCornerJointMetadata, + resolveLeanToCornerJoints, +} from './corner-joint' +import { leanToLowEdgeHeight, resolveLeanToPlanCenter, resolveLeanToWallPlacement } from './layout' import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' import { applyLeanToAvailableWallSpan, @@ -30,6 +35,54 @@ export function resolveLeanToCommitTarget( return visibleTarget ?? clickTarget } +/** Apply transient corner data so the placement ghost matches the committed assembly. */ +export function resolveLeanToPreviewNode( + node: LeanToExtensionNode, + wall: WallNode | undefined, + nodes: Record, +): LeanToExtensionNode { + if (!wall) return node + const joints = resolveLeanToCornerJoints(node, wall, nodes) + if (Object.keys(joints).length === 0) return node + return { + ...node, + leftEndCondition: joints.left ? 'joined' : node.leftEndCondition, + rightEndCondition: joints.right ? 'joined' : node.rightEndCondition, + metadata: { + ...(node.metadata && typeof node.metadata === 'object' ? node.metadata : {}), + [LEAN_TO_CORNER_JOINTS_KEY]: leanToCornerJointMetadata(joints), + }, + } +} + +export function resolveLeanToWallPlanTarget( + wall: WallNode, + localX: number, + side: 'front' | 'back', + nodes: Record, +): LeanToPlanPlacementTarget | null { + const wallPlacement = resolveLeanToWallPlacement(wall, localX, side) + if (!wallPlacement) return null + + const attachment = resolveLeanToRoofAttachment(wallPlacement, wall, nodes) + const autoSpannedNode = attachment + ? applyLeanToRoofAttachment(wallPlacement, attachment) + : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), wall) + const attachedNode = applyLeanToAvailableWallSpan( + autoSpannedNode, + wall, + nodes, + wallPlacement.position[0], + ) + const node = resolveLeanToEndAbutments(attachedNode, wall, nodes) + const previewNode = resolveLeanToPreviewNode(node, wall, nodes) + return { + node: previewNode, + valid: leanToPlacementConflicts(node, wall, nodes).length === 0, + wall, + } +} + const PLACEMENT_ROTATION_STEP = Math.PI / 4 export function nextLeanToPlacementRotation( @@ -43,6 +96,21 @@ export function nextLeanToPlacementRotation( 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], @@ -55,11 +123,12 @@ export function resolveLeanToFreestandingPlacement( highSideMode: 'independent-high-beam', connectionMode: 'manual', autoSpan: false, - position: [point[0], 0, point[1]], + position: [0, 0, 0], rotation: [0, rotationY, 0], }) return { ...parsed, + position: resolveLeanToPlanPosition(parsed, point), hostRoofId: undefined, hostRoofSegmentId: undefined, hostRoofEdge: undefined, @@ -81,27 +150,10 @@ export function resolveLeanToPlanPlacement({ nodes: Record point: readonly [number, number] }): LeanToPlanPlacementTarget { - const hit = findClosestWallInPlan(point, nodes, activeLevelId) + const hit = findClosestWallAttachmentInPlan(point, nodes, activeLevelId) if (hit) { - const wallPlacement = resolveLeanToWallPlacement(hit.wall, hit.localX, hit.side) - if (wallPlacement) { - const attachment = resolveLeanToRoofAttachment(wallPlacement, hit.wall, nodes) - const autoSpannedNode = attachment - ? applyLeanToRoofAttachment(wallPlacement, attachment) - : applyLeanToWallAutoSpan(clearLeanToRoofAttachment(wallPlacement), hit.wall) - const attachedNode = applyLeanToAvailableWallSpan( - autoSpannedNode, - hit.wall, - nodes, - wallPlacement.position[0], - ) - const node = resolveLeanToEndAbutments(attachedNode, hit.wall, nodes) - return { - node, - valid: leanToPlacementConflicts(node, hit.wall, nodes).length === 0, - wall: hit.wall, - } - } + const target = resolveLeanToWallPlanTarget(hit.wall, hit.localX, hit.side, nodes) + if (target) return target } const slabAttached = findLeanToSlabEdgePlacement(point, nodes, activeLevelId) 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 909dabbd1f..774d036de2 100644 --- a/packages/nodes/src/lean-to-extension/roof-corner.test.ts +++ b/packages/nodes/src/lean-to-extension/roof-corner.test.ts @@ -707,6 +707,61 @@ 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', diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx index 6fed9aaacb..72e71304da 100644 --- a/packages/nodes/src/lean-to-extension/tool.tsx +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -28,29 +28,18 @@ import { stopPlacementCommitPropagation } from '../shared/floor-placement' import { createLeanToAssembly } from './assembly' import { isConicalLeanToHostOccupied, resolveConicalLeanToSurfaceHit } from './conical-host' import { leanToExtensionGeometryKey } from './geometry' -import { - leanToWallLocalPose, - resolveLeanToWallPlacement, - resolveLeanToWallSurfaceHit, -} from './layout' +import { leanToWallLocalPose, resolveLeanToWallSurfaceHit } from './layout' import { findLeanToSlabEdgePlacement, type LeanToPlanPlacementTarget, nextLeanToPlacementRotation, resolveLeanToCommitTarget, resolveLeanToPlanPlacement, + resolveLeanToWallPlanTarget, } from './placement' import { isLeanToHostOnLevel } from './placement-scope' -import { leanToPlacementConflicts, resolveLeanToEndAbutments } from './placement-validation' 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' @@ -288,35 +277,28 @@ const LeanToExtensionTool = () => { setPreview(null) return null } - const wallPlacement = resolveLeanToWallPlacement(event.node, hit.localX, hit.side) - if (!wallPlacement) { + const target = resolveLeanToWallPlanTarget(event.node, hit.localX, hit.side, nodes) + if (!target) { lastPreviewTarget = null setPreview(null) return null } - 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) - const valid = leanToPlacementConflicts(node, event.node, nodes).length === 0 - const pose = leanToWallLocalPose(event.node, node, resolveBaseY(event.node)) - lastPreviewTarget = { node, parentId: event.node.id as AnyNodeId, valid } + 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, + valid: target.valid, })) - return valid ? node : null + return target.valid ? target.node : null } const onWallMove = (event: WallEvent) => { diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index 0c6dc53e65..44aeec0237 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -3,7 +3,11 @@ import { type AnyNodeId, collectLevelWallSegments, getScaledDimensions, + getWallArcData, + getWallCurveFrameAt, + getWallCurveLength, type ItemNode, + isCurvedWall, nearestWallSegment, useScene, WALL_SNAP_DISTANCE_M, @@ -109,6 +113,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 From c03ab308903427714636c7a3a6de6c5f6939038d Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 25 Aug 2026 15:15:49 +0530 Subject: [PATCH 8/9] Align placement previews with architecture --- .../src/components/tools/roof/roof-tool.tsx | 2 +- packages/nodes/src/door/floorplan-move.ts | 1 + packages/nodes/src/door/move-tool.tsx | 1 + packages/nodes/src/door/tool.tsx | 10 ++++- packages/nodes/src/dormer/renderer.tsx | 17 +++++++-- .../floorplan-affordances.ts | 4 +- .../src/roof-segment/floorplan-affordances.ts | 6 +-- .../nodes/src/shared/wall-attach-target.ts | 5 +-- packages/nodes/src/window/floorplan-move.ts | 1 + packages/nodes/src/window/move-tool.tsx | 37 ++++++++++++++----- packages/nodes/src/window/renderer.tsx | 16 ++++---- packages/nodes/src/window/tool.tsx | 25 ++++++++++--- 12 files changed, 89 insertions(+), 36 deletions(-) diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 9d1f42c8da..daff86df91 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -527,7 +527,7 @@ export const RoofTool: React.FC = () => { // bodies on the active level + floor below (raising the green beacon), // falling back to alignment guides, then to the world-grid snap. The same // path the slab/ceiling tools use, so the beacon and coloring match. The - // pipeline reads the snapping mode itself (Shift bypass, magnetic on/off), + // pipeline reads the active snapping mode (grid / lines / angles / off), // so this tool never inspects the flags. `levelId` is intentionally omitted // so the explicit floor-below `walls` aren't filtered back out. const resolveDraftPoint = (event: GridEvent): [number, number] => { 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/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index f021857d46..0fabf50211 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -44,6 +44,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { // 32-segment arch curves). Commit clears the override and the real // CSG mesh kicks back in. const liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id as AnyNodeId)) + const liveWindowOverrides = useLiveNodeOverrides((state) => state.overrides) const isLiveDrag = !!liveOverrides && Object.keys(liveOverrides).length > 0 const node = useMemo( () => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as DormerNode) : storeNode), @@ -58,8 +59,17 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { ), ) const hostedWindows = useMemo( - () => childNodes.filter((child): child is WindowNode => child.type === 'window'), - [childNodes], + () => + childNodes + .filter((child): child is WindowNode => child.type === 'window') + .map((window) => { + const override = liveWindowOverrides.get(window.id) + return override ? ({ ...window, ...override } as WindowNode) : window + }), + [childNodes, liveWindowOverrides], + ) + const hasLiveWindowPreview = childNodes.some( + (child) => child.type === 'window' && liveWindowOverrides.has(child.id), ) const segment = useScene((state) => @@ -119,10 +129,11 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { // 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) + if (isLiveDrag || hasLiveWindowPreview) return buildDormerFallbackGeometry(node) return generateDormerGeometry(node, segment, hostedWindows) }, [ isLiveDrag, + hasLiveWindowPreview, segment, node.id, node.roofType, diff --git a/packages/nodes/src/lean-to-extension/floorplan-affordances.ts b/packages/nodes/src/lean-to-extension/floorplan-affordances.ts index 0ba44ac798..7bfe540488 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-affordances.ts +++ b/packages/nodes/src/lean-to-extension/floorplan-affordances.ts @@ -10,7 +10,7 @@ import { useLiveNodeOverrides, type WallNode, } from '@pascal-app/core' -import { getSegmentGridStep, isAngleSnapActive } from '@pascal-app/editor' +import { getSegmentGridStep, isAngleSnapActive, isGridSnapActive } from '@pascal-app/editor' import { rotateAffordanceDelta } from '../shared/rotate-affordance' import { resolveLeanToEdgeSnapTargets, @@ -60,7 +60,7 @@ export const leanToResizeAffordance: FloorplanAffordance = apply({ planPoint, modifiers }) { const currentAxis = planPoint[0] * axis[0] + planPoint[1] * axis[1] const raw = initialValue + (currentAxis - initialAxis) * side - const step = modifiers.altKey ? 0 : getSegmentGridStep() + const step = !modifiers.altKey && isGridSnapActive() ? getSegmentGridStep() : 0 const value = Math.max(0.5, step > 0 ? snapScalar(raw, step) : raw) if (dimension === 'projection') { lastPatch = { diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.ts b/packages/nodes/src/roof-segment/floorplan-affordances.ts index 3d282a67ef..8de6640b57 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -8,7 +8,7 @@ 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' @@ -121,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 @@ -236,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/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index 44aeec0237..39fe520206 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -9,7 +9,6 @@ import { type ItemNode, isCurvedWall, nearestWallSegment, - useScene, WALL_SNAP_DISTANCE_M, type WallNode, } from '@pascal-app/core' @@ -335,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 @@ -407,7 +406,7 @@ export type OpeningPlacement = { /** * Resolve the placement state from the raw collision result and whether the - * user is force-placing (Shift). Force-place lifts the collision block, so the + * user is force-placing (held Alt). Force-place lifts the collision block, so the * opening becomes placeable AND the tint goes green — the preview and the * commit gate stay in lockstep because both read this one result. */ diff --git a/packages/nodes/src/window/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 744f9585be..655ea55280 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -10,6 +10,7 @@ import { type RoofNode, sceneRegistry, spatialGridManager, + useLiveNodeOverrides, useLiveTransforms, useScene, type WallEvent, @@ -42,7 +43,6 @@ import { dormerEventFromHostedWindow, getDormerWindowWorldYaw, resolveDormerWindowTarget, - shouldWriteDormerWindowPreviewHost, } from '../shared/dormer-wall-opening-placement' import { clearOpeningGuides3D, @@ -415,6 +415,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const valid = !hasWallChildOverlap( event.node.id, + useScene.getState().nodes, clampedX, clampedY, movingWindowNode.width, @@ -446,6 +447,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // ghost so validity reads at a glance (see MoveDoorTool). The node position // is still written so the wall cuts the hole at the right spot. if (currentHostId !== target.wallId) { + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useScene.getState().updateNode(movingWindowNode.id, { position: [target.clampedX, target.clampedY, 0], rotation: [0, target.itemRotation, 0], @@ -723,6 +725,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() @@ -742,7 +745,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) currentHostId = levelId } else { - useScene.getState().updateNode(movingWindowNode.id, { + useLiveNodeOverrides.getState().set(movingWindowNode.id, { position: [localX, sillCenterY, localZ], rotation: [0, yaw, 0], side: sideOverride, @@ -806,11 +809,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode 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 - } - const liveNode = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as WindowNode - if (shouldWriteDormerWindowPreviewHost(liveNode, target)) { + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useScene.getState().updateNode(movingWindowNode.id, { position: target.position, rotation, @@ -823,8 +822,21 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofFace: undefined, visible: false, }) - markHostDirtyThrottled(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, + }) setGhostPose({ position: dormerWindowWorldPosition(event, target), rotationY: getDormerWindowWorldYaw(event, target), @@ -839,6 +851,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode 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 @@ -933,6 +946,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onDormerLeave = () => { hideCursor() + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id) lastDormerEvent = null lastDormerTarget = null @@ -1006,12 +1020,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode grabWallId = null lastTarget = null lastRoofEvent = event + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id) // Opening guides are wall-specific; clear them when over a roof face. clearOpeningGuides3D() // On a roof face the real mesh is the preview — drop the ghost + reveal. revealRealNode() if (currentHostId !== target.segment.id) { + useLiveNodeOverrides.getState().clear(movingWindowNode.id) useScene.getState().updateNode(movingWindowNode.id, { position: target.position, rotation: [0, 0, 0], @@ -1025,7 +1041,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode markHostDirty(currentHostId) currentHostId = target.segment.id } else { - useScene.getState().updateNode(movingWindowNode.id, { + useLiveNodeOverrides.getState().set(movingWindowNode.id, { position: target.position, rotation: [0, 0, 0], roofFace: target.face.id, @@ -1121,6 +1137,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 @@ -1128,6 +1145,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) @@ -1319,6 +1337,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() 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 e9da161545..d16a0efacf 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -6,12 +6,14 @@ import { dormerWallFacePointToDormer, emitter, type GridEvent, + getEffectiveNode, holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, type RoofNode, sceneRegistry, spatialGridManager, + useLiveNodeOverrides, useScene, type WallEvent, type WallNode, @@ -159,7 +161,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 @@ -205,6 +207,7 @@ const WindowTool: React.FC = () => { return } const wallId = draft.parentId + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) draftRef.current = null clearPlacementPreview() @@ -329,7 +332,7 @@ const WindowTool: React.FC = () => { useScene.getState().createNode(node, event.node.id as AnyNodeId) draftRef.current = node } else { - useScene.getState().updateNode(draftRef.current.id, { + useLiveNodeOverrides.getState().set(draftRef.current.id, { position: target.position, rotation: [0, itemRotation, 0], side, @@ -416,7 +419,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 } } @@ -461,13 +472,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], @@ -525,6 +537,7 @@ const WindowTool: React.FC = () => { draftRef.current = null hostKind = null + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -582,6 +595,7 @@ const WindowTool: React.FC = () => { draftRef.current = null hostKind = null + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -847,7 +861,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, @@ -885,6 +899,7 @@ const WindowTool: React.FC = () => { draftRef.current = null hostKind = null + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() From 1f790d32a82c96e45e573df9fdff1c80ef65cadf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 25 Aug 2026 16:32:44 +0530 Subject: [PATCH 9/9] Fix dormer window placement grid orientation --- .../dormer-wall-opening-placement.test.ts | 22 +++++++++++++++++++ .../shared/dormer-wall-opening-placement.ts | 12 ++++++++-- packages/nodes/src/window/move-tool.tsx | 10 +++++++-- packages/nodes/src/window/tool.tsx | 14 +++++++----- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts b/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts index d06bcbba58..4ff4d57814 100644 --- a/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts +++ b/packages/nodes/src/shared/dormer-wall-opening-placement.test.ts @@ -9,6 +9,7 @@ import { import { Object3D } from 'three' import { dormerEventFromHostedWindow, + getDormerWindowWorldNormal, getDormerWindowWorldYaw, resolveDormerWindowTarget, shouldWriteDormerWindowPreviewHost, @@ -238,6 +239,27 @@ describe('getDormerWindowWorldYaw', () => { }) }) +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' }) diff --git a/packages/nodes/src/shared/dormer-wall-opening-placement.ts b/packages/nodes/src/shared/dormer-wall-opening-placement.ts index 70a9439d69..bdd2ffab70 100644 --- a/packages/nodes/src/shared/dormer-wall-opening-placement.ts +++ b/packages/nodes/src/shared/dormer-wall-opening-placement.ts @@ -49,12 +49,20 @@ export function dormerEventFromHostedWindow( } 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) - dormerFaceNormal + return out .set(Math.sin(frame.yaw), 0, Math.cos(frame.yaw)) .transformDirection(event.object.matrixWorld) - return Math.atan2(dormerFaceNormal.x, dormerFaceNormal.z) } export function shouldWriteDormerWindowPreviewHost( diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 655ea55280..651113ed2e 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -41,6 +41,7 @@ import { LineBasicNodeMaterial } from 'three/webgpu' import { type DormerWindowTarget, dormerEventFromHostedWindow, + getDormerWindowWorldNormal, getDormerWindowWorldYaw, resolveDormerWindowTarget, } from '../shared/dormer-wall-opening-placement' @@ -837,11 +838,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofFace: undefined, visible: false, }) + const worldPosition = dormerWindowWorldPosition(event, target) + publishPlacementSurface( + new Vector3(...worldPosition), + getDormerWindowWorldNormal(event, target), + ) setGhostPose({ - position: dormerWindowWorldPosition(event, target), + position: worldPosition, rotationY: getDormerWindowWorldYaw(event, target), tint: target.valid || altHeld ? 'valid' : 'invalid', - floorY: dormerWindowWorldPosition(event, target)[1], + floorY: worldPosition[1], side, }) useFacingPose.getState().clear() diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index d16a0efacf..9458fea098 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -24,10 +24,12 @@ import { import { calculateCursorRotation, calculateItemRotation, + clearPlacementSurface, EDITOR_LAYER, getSideFromNormal, isMagneticSnapActive, isValidWallSideFace, + publishPlacementSurface, snapToHalf, triggerSFX, useAlignmentGuides, @@ -42,6 +44,7 @@ import { LineBasicNodeMaterial } from 'three/webgpu' import { type DormerWindowTarget, dormerEventFromHostedWindow, + getDormerWindowWorldNormal, getDormerWindowWorldYaw, resolveDormerWindowTarget, } from '../shared/dormer-wall-opening-placement' @@ -221,6 +224,7 @@ const WindowTool: React.FC = () => { clearOpeningGuides3D() setFallbackPose(null) useFacingPose.getState().clear() + clearPlacementSurface() clearPlacementPreview() } @@ -347,12 +351,12 @@ const WindowTool: React.FC = () => { publishDraftPreview(event.node) clearOpeningGuides3D() - updateCursor( - dormerWindowWorldPosition(event, target), - getDormerWindowWorldYaw(event, target), - target.valid, - 0, + 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